{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "phase-builder",
  "type": "registry:component",
  "title": "Phase Builder",
  "description": "Lay out one stretch of a contract with its products, cadence, discount and minimum, and watch each month's invoice settle into place as you go.",
  "categories": [
    "component",
    "Forms"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/badge.json",
    "https://coss.com/ui/r/button.json",
    "https://coss.com/ui/r/calendar.json",
    "https://coss.com/ui/r/menu.json",
    "https://coss.com/ui/r/number-field.json",
    "https://coss.com/ui/r/popover.json",
    "https://coss.com/ui/r/select.json",
    "https://coss.com/ui/r/switch.json"
  ],
  "files": [
    {
      "path": "registry/devl/phase-builder.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CalendarIcon, PackageIcon, PlusIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useMemo, useState } from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport { Menu, MenuItem, MenuPopup, MenuTrigger } from \"@/components/ui/menu\";\nimport {\n  NumberField,\n  NumberFieldDecrement,\n  NumberFieldGroup,\n  NumberFieldIncrement,\n  NumberFieldInput,\n} from \"@/components/ui/number-field\";\nimport { Popover, PopoverPopup, PopoverTrigger } from \"@/components/ui/popover\";\nimport { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { cn } from \"@/lib/utils\";\n\nexport type BillingRepeat = \"monthly\" | \"quarterly\" | \"annually\" | \"upfront\";\nexport type BillingTiming = \"advance\" | \"arrears\";\n\nexport interface BillingProduct {\n  id: string;\n  name: string;\n  price: number;\n  unit: string;\n  oneTime?: boolean;\n}\n\nexport interface BillingLine {\n  productId: string;\n  quantity: number;\n  repeat: BillingRepeat;\n  timing: BillingTiming;\n}\n\nexport interface BillingPhase {\n  name: string;\n  start: Date;\n  months: number;\n  lines: BillingLine[];\n  discount: { enabled: boolean; percent: number };\n  minimum: { enabled: boolean; amount: number };\n}\n\nexport interface BillingTotals {\n  subtotal: number;\n  discount: number;\n  trueUp: number;\n  total: number;\n  invoices: number[];\n}\n\nexport interface PhaseBuilderProps {\n  catalog: BillingProduct[];\n  defaultPhase: BillingPhase;\n  currency?: string;\n  locale?: string;\n  onChange?: (phase: BillingPhase, totals: BillingTotals) => void;\n  className?: string;\n}\n\nconst repeatLabels: Record<BillingRepeat, string> = {\n  monthly: \"Monthly\",\n  quarterly: \"Quarterly\",\n  annually: \"Annually\",\n  upfront: \"Upfront\",\n};\nconst repeatMonths: Record<BillingRepeat, number> = { monthly: 1, quarterly: 3, annually: 12, upfront: Infinity };\nconst timingLabels: Record<BillingTiming, string> = { advance: \"In advance\", arrears: \"In arrears\" };\n\nfunction addMonths(d: Date, n: number) {\n  return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());\n}\n\nfunction computeTotals(phase: BillingPhase, catalog: BillingProduct[]): BillingTotals & { trueUpMonth: number } {\n  const n = Math.max(1, phase.months);\n  const invoices = Array.from({ length: n }, () => 0);\n  let subtotal = 0;\n  let discount = 0;\n  const pct = phase.discount.enabled ? phase.discount.percent : 0;\n\n  for (const line of phase.lines) {\n    const p = catalog.find((c) => c.id === line.productId);\n    if (!p) continue;\n    if (p.oneTime) {\n      const amt = p.price * line.quantity;\n      const month = line.timing === \"advance\" ? 0 : n - 1;\n      invoices[month] += amt;\n      subtotal += amt;\n      continue;\n    }\n    const period = Math.min(repeatMonths[line.repeat], n);\n    for (let start = 0; start < n; start += period) {\n      const len = Math.min(period, n - start);\n      const gross = p.price * line.quantity * len;\n      const off = gross * pct;\n      const month = line.timing === \"advance\" ? start : start + len - 1;\n      invoices[month] += gross - off;\n      subtotal += gross;\n      discount += off;\n    }\n  }\n\n  const net = subtotal - discount;\n  const trueUp = phase.minimum.enabled ? Math.max(0, phase.minimum.amount - net) : 0;\n  if (trueUp) invoices[n - 1] += trueUp;\n  return { subtotal, discount, trueUp, total: net + trueUp, invoices, trueUpMonth: n - 1 };\n}\n\nfunction Rolling({ value, format, className }: { value: number; format: (n: number) => string; className?: string }) {\n  const reduce = useReducedMotion();\n  const text = format(value);\n  const chars = text.split(\"\");\n  return (\n    <span className={cn(\"relative inline-flex tabular-nums leading-[1.2]\", className)}>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden className=\"inline-flex\">\n        {chars.map((ch, i) => {\n          const key = chars.length - i;\n          if (!/\\d/.test(ch)) {\n            return (\n              <span className=\"inline-block\" key={`s${key}${ch}`}>\n                {ch}\n              </span>\n            );\n          }\n          const d = Number(ch);\n          return (\n            <span className=\"relative inline-block h-[1.2em] overflow-hidden\" key={`d${key}`}>\n              <span className=\"invisible\">0</span>\n              <motion.span\n                animate={{ y: `${-d * 1.2}em` }}\n                className=\"absolute inset-x-0 top-0 flex flex-col\"\n                initial={false}\n                transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 260, damping: 28 }}\n              >\n                {\"0123456789\".split(\"\").map((n) => (\n                  <span className=\"block h-[1.2em]\" key={n}>\n                    {n}\n                  </span>\n                ))}\n              </motion.span>\n            </span>\n          );\n        })}\n      </span>\n    </span>\n  );\n}\n\nconst press = \"transition-[scale,box-shadow,background-color] duration-150 ease-out active:scale-[0.97]\";\n\nconst pillTrigger =\n  \"h-7 min-h-7 w-auto min-w-0 gap-1 rounded-md border-transparent bg-muted px-2 text-xs shadow-none before:hidden sm:h-6 sm:min-h-6 sm:text-xs dark:bg-input/48 [&_svg]:size-3!\";\n\nfunction Field({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) {\n  return (\n    <div className=\"flex min-w-0 flex-col gap-1.5\">\n      <div className=\"flex h-5 items-center justify-between gap-2\">\n        <span className=\"font-medium text-muted-foreground text-xs\">{label}</span>\n        {action}\n      </div>\n      {children}\n    </div>\n  );\n}\n\nexport function PhaseBuilder({\n  catalog,\n  defaultPhase,\n  currency = \"USD\",\n  locale = \"en-US\",\n  onChange,\n  className,\n}: PhaseBuilderProps) {\n  const reduce = useReducedMotion();\n  const id = useId();\n  const [phase, setPhase] = useState(defaultPhase);\n  const [hover, setHover] = useState<number | null>(null);\n  const [dateOpen, setDateOpen] = useState(false);\n\n  const totals = useMemo(() => computeTotals(phase, catalog), [phase, catalog]);\n  const update = (next: BillingPhase) => {\n    setPhase(next);\n    onChange?.(next, computeTotals(next, catalog));\n  };\n  const setLine = (i: number, patch: Partial<BillingLine>) =>\n    update({ ...phase, lines: phase.lines.map((l, j) => (j === i ? { ...l, ...patch } : l)) });\n\n  const money = useMemo(() => {\n    const whole = new Intl.NumberFormat(locale, { style: \"currency\", currency, maximumFractionDigits: 0 });\n    const cents = new Intl.NumberFormat(locale, { style: \"currency\", currency });\n    return { whole: (n: number) => whole.format(Math.round(n)), cents: (n: number) => cents.format(n) };\n  }, [currency, locale]);\n  const dateFmt = useMemo(() => new Intl.DateTimeFormat(locale, { month: \"short\", day: \"numeric\", year: \"numeric\" }), [locale]);\n  const monthFmt = useMemo(() => new Intl.DateTimeFormat(locale, { month: \"short\", year: \"numeric\" }), [locale]);\n  const tickFmt = useMemo(() => new Intl.DateTimeFormat(locale, { month: \"short\" }), [locale]);\n\n  const end = addMonths(phase.start, phase.months);\n  end.setDate(end.getDate() - 1);\n  const max = Math.max(...totals.invoices, 1);\n  const available = catalog.filter((c) => !phase.lines.some((l) => l.productId === c.id));\n  const shown = hover ?? 0;\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 420, damping: 34 };\n\n  return (\n    <section\n      aria-labelledby={`${id}-title`}\n      className={cn(\"w-full max-w-[600px] rounded-2xl border bg-card text-card-foreground shadow-xs/5\", className)}\n    >\n      <header className=\"flex items-start justify-between gap-3 px-4 pt-4 pb-3\">\n        <div className=\"min-w-0\">\n          <h3 className=\"truncate font-medium text-sm\" id={`${id}-title`}>\n            {phase.name}\n          </h3>\n          <p className=\"truncate text-muted-foreground text-xs tabular-nums\">\n            {dateFmt.format(phase.start)} → {dateFmt.format(end)}\n          </p>\n        </div>\n        <Badge variant=\"outline\">Draft</Badge>\n      </header>\n\n      <div className=\"grid grid-cols-2 gap-x-3 gap-y-3 px-4 pb-4 sm:grid-cols-[1.2fr_1.25fr_0.85fr_1fr]\">\n        <Field label=\"Start\">\n          <Popover onOpenChange={setDateOpen} open={dateOpen}>\n            <PopoverTrigger\n              render={\n                <Button className=\"w-full justify-start px-2.5 font-normal text-sm tabular-nums\" size=\"sm\" variant=\"outline\" />\n              }\n            >\n              <CalendarIcon />\n              <span className=\"truncate\">{dateFmt.format(phase.start)}</span>\n            </PopoverTrigger>\n            <PopoverPopup align=\"start\">\n              <Calendar\n                className=\"[--cell-size:--spacing(8)]\"\n                defaultMonth={phase.start}\n                mode=\"single\"\n                onSelect={(d: Date | undefined) => {\n                  if (d) update({ ...phase, start: d });\n                  setDateOpen(false);\n                }}\n                selected={phase.start}\n              />\n            </PopoverPopup>\n          </Popover>\n        </Field>\n        <Field label=\"Duration\">\n          <NumberField\n            format={{ style: \"unit\", unit: \"month\", unitDisplay: \"long\" }}\n            locale={locale}\n            max={36}\n            min={1}\n            onValueChange={(v) => update({ ...phase, months: Math.min(36, Math.max(1, v ?? 1)) })}\n            size=\"sm\"\n            value={phase.months}\n          >\n            <NumberFieldGroup className=\"[&>button]:px-2\">\n              <NumberFieldDecrement />\n              <NumberFieldInput aria-label=\"Duration in months\" className=\"px-0! text-sm\" />\n              <NumberFieldIncrement />\n            </NumberFieldGroup>\n          </NumberField>\n        </Field>\n        <Field\n          action={\n            <Switch\n              aria-label=\"Apply discount\"\n              checked={phase.discount.enabled}\n              onCheckedChange={(v) => update({ ...phase, discount: { ...phase.discount, enabled: v } })}\n            />\n          }\n          label=\"Discount\"\n        >\n          <NumberField\n            disabled={!phase.discount.enabled}\n            format={{ style: \"percent\" }}\n            locale={locale}\n            max={1}\n            min={0}\n            onValueChange={(v) => update({ ...phase, discount: { ...phase.discount, percent: v ?? 0 } })}\n            size=\"sm\"\n            smallStep={0.01}\n            step={0.01}\n            value={phase.discount.percent}\n          >\n            <NumberFieldGroup>\n              <NumberFieldInput aria-label=\"Discount percent\" className=\"px-2.5! text-left text-sm\" />\n            </NumberFieldGroup>\n          </NumberField>\n        </Field>\n        <Field\n          action={\n            <Switch\n              aria-label=\"Minimum commitment\"\n              checked={phase.minimum.enabled}\n              onCheckedChange={(v) => update({ ...phase, minimum: { ...phase.minimum, enabled: v } })}\n            />\n          }\n          label=\"Minimum\"\n        >\n          <NumberField\n            disabled={!phase.minimum.enabled}\n            format={{ style: \"currency\", currency, maximumFractionDigits: 0 }}\n            locale={locale}\n            min={0}\n            onValueChange={(v) => update({ ...phase, minimum: { ...phase.minimum, amount: v ?? 0 } })}\n            size=\"sm\"\n            step={1000}\n            value={phase.minimum.amount}\n          >\n            <NumberFieldGroup>\n              <NumberFieldInput aria-label=\"Minimum commitment amount\" className=\"px-2.5! text-left text-sm\" />\n            </NumberFieldGroup>\n          </NumberField>\n        </Field>\n      </div>\n\n      <div className=\"border-t px-4 py-3\">\n        <div className=\"mb-1 flex h-7 items-center justify-between\">\n          <span className=\"font-medium text-sm\">\n            Products <span className=\"text-muted-foreground tabular-nums\">{phase.lines.length}</span>\n          </span>\n          <Menu>\n            <MenuTrigger\n              disabled={available.length === 0}\n              render={<Button className={cn(\"-me-1.5\", press)} size=\"sm\" variant=\"ghost\" />}\n            >\n              <PlusIcon />\n              Add product\n            </MenuTrigger>\n            <MenuPopup align=\"end\">\n              {available.map((p) => (\n                <MenuItem\n                  key={p.id}\n                  onClick={() =>\n                    update({\n                      ...phase,\n                      lines: [\n                        ...phase.lines,\n                        {\n                          productId: p.id,\n                          quantity: 1,\n                          repeat: p.oneTime ? \"upfront\" : \"monthly\",\n                          timing: \"advance\",\n                        },\n                      ],\n                    })\n                  }\n                >\n                  <PackageIcon />\n                  <span className=\"flex-1\">{p.name}</span>\n                  <span className=\"ps-4 text-muted-foreground text-xs tabular-nums\">\n                    {money.whole(p.price)}\n                    {p.oneTime ? \"\" : \"/mo\"}\n                  </span>\n                </MenuItem>\n              ))}\n            </MenuPopup>\n          </Menu>\n        </div>\n\n        <motion.ul className=\"flex flex-col\" layout transition={spring}>\n          <AnimatePresence initial={false}>\n            {phase.lines.map((line, i) => {\n              const p = catalog.find((c) => c.id === line.productId);\n              if (!p) return null;\n              const lineTotal = computeTotals({ ...phase, lines: [line], minimum: { enabled: false, amount: 0 } }, catalog).total;\n              return (\n                <motion.li\n                  animate={{ opacity: 1, height: \"auto\" }}\n                  className=\"overflow-hidden\"\n                  exit={{ opacity: 0, height: 0 }}\n                  initial={reduce ? false : { opacity: 0, height: 0 }}\n                  key={line.productId}\n                  layout=\"position\"\n                  transition={spring}\n                >\n                  <div className=\"flex flex-col gap-1.5 border-t py-2 in-[li:first-child]:border-t-0\">\n                    <div className=\"flex items-baseline gap-2\">\n                      <span className=\"truncate font-medium text-sm\">{p.name}</span>\n                      <span className=\"hidden shrink-0 text-muted-foreground text-xs tabular-nums min-[400px]:inline\">\n                        {money.whole(p.price)}\n                        {p.oneTime ? \" flat\" : ` / ${p.unit} / mo`}\n                      </span>\n                      <Rolling className=\"ms-auto shrink-0 font-medium text-sm\" format={money.whole} value={lineTotal} />\n                      <Button\n                        aria-label={`Remove ${p.name}`}\n                        className=\"-me-1 self-center text-muted-foreground transition-[scale,background-color] duration-150 ease-out after:absolute after:-inset-1 active:scale-[0.94]\"\n                        onClick={() => update({ ...phase, lines: phase.lines.filter((_, j) => j !== i) })}\n                        size=\"icon-xs\"\n                        variant=\"ghost\"\n                      >\n                        <XIcon />\n                      </Button>\n                    </div>\n                    <div className=\"flex flex-wrap items-center gap-1.5\">\n                      <NumberField\n                        className=\"me-1 w-24\"\n                        max={999}\n                        min={1}\n                        onValueChange={(v) => setLine(i, { quantity: Math.max(1, v ?? 1) })}\n                        size=\"sm\"\n                        value={line.quantity}\n                      >\n                        <NumberFieldGroup className=\"h-7 sm:h-6 [&_button]:px-1.5\">\n                          <NumberFieldDecrement />\n                          <NumberFieldInput aria-label={`${p.name} quantity`} className=\"h-full! px-0! text-xs leading-none!\" />\n                          <NumberFieldIncrement />\n                        </NumberFieldGroup>\n                      </NumberField>\n                      {p.oneTime ? (\n                        <span className=\"rounded-md bg-muted px-2 py-1 text-muted-foreground text-xs sm:py-0.5 dark:bg-input/48\">\n                          One-time\n                        </span>\n                      ) : (\n                        <Select\n                          items={Object.entries(repeatLabels).map(([value, label]) => ({ value, label }))}\n                          onValueChange={(v) => v && setLine(i, { repeat: v as BillingRepeat })}\n                          value={line.repeat}\n                        >\n                          <SelectTrigger aria-label={`${p.name} repeat`} className={pillTrigger} size=\"sm\">\n                            <SelectValue />\n                          </SelectTrigger>\n                          <SelectPopup>\n                            {(Object.keys(repeatLabels) as BillingRepeat[]).map((r) => (\n                              <SelectItem key={r} value={r}>\n                                {repeatLabels[r]}\n                              </SelectItem>\n                            ))}\n                          </SelectPopup>\n                        </Select>\n                      )}\n                      <Select\n                        items={Object.entries(timingLabels).map(([value, label]) => ({ value, label }))}\n                        onValueChange={(v) => v && setLine(i, { timing: v as BillingTiming })}\n                        value={line.timing}\n                      >\n                        <SelectTrigger aria-label={`${p.name} timing`} className={pillTrigger} size=\"sm\">\n                          <SelectValue />\n                        </SelectTrigger>\n                        <SelectPopup>\n                          {(Object.keys(timingLabels) as BillingTiming[]).map((t) => (\n                            <SelectItem key={t} value={t}>\n                              {timingLabels[t]}\n                            </SelectItem>\n                          ))}\n                        </SelectPopup>\n                      </Select>\n                    </div>\n                  </div>\n                </motion.li>\n              );\n            })}\n          </AnimatePresence>\n        </motion.ul>\n        {phase.lines.length === 0 && (\n          <p className=\"rounded-xl border border-dashed py-6 text-center text-muted-foreground text-xs\">\n            No products yet. Add one to see the schedule.\n          </p>\n        )}\n      </div>\n\n      <div className=\"rounded-b-2xl border-t bg-muted/40 p-4\">\n        <div className=\"flex flex-wrap items-end justify-between gap-x-6 gap-y-3\">\n          <div>\n            <p className=\"text-muted-foreground text-xs\">Contract value</p>\n            <Rolling className=\"font-medium text-2xl tracking-tight\" format={money.whole} value={totals.total} />\n          </div>\n          <dl className=\"grid grid-cols-3 gap-x-5 text-xs sm:text-right\">\n            <dt className=\"text-muted-foreground\">First invoice</dt>\n            <dt className=\"text-muted-foreground\">Per month</dt>\n            <dt className=\"text-muted-foreground\">Discount</dt>\n            <dd className=\"font-medium\">\n              <Rolling format={money.whole} value={totals.invoices[0] ?? 0} />\n            </dd>\n            <dd className=\"font-medium\">\n              <Rolling format={money.whole} value={totals.total / phase.months} />\n            </dd>\n            <dd className=\"font-medium\">\n              <Rolling format={(n) => (n ? `−${money.whole(n)}` : money.whole(0))} value={totals.discount} />\n            </dd>\n          </dl>\n        </div>\n\n        <div className=\"mt-4 mb-1.5 flex h-4 items-center justify-between gap-3 text-xs\">\n          <span className=\"truncate text-muted-foreground\">\n            {totals.invoices.filter((v) => v > 0).length} invoices\n          </span>\n          <span className=\"shrink-0 tabular-nums\">\n            <span className=\"text-muted-foreground\">{monthFmt.format(addMonths(phase.start, shown))} · </span>\n            <span className=\"font-medium\">{money.cents(totals.invoices[shown] ?? 0)}</span>\n          </span>\n        </div>\n        <div\n          aria-label=\"Amount invoiced per month\"\n          className=\"flex h-12 items-end gap-[3px]\"\n          onMouseLeave={() => setHover(null)}\n          role=\"img\"\n        >\n          {totals.invoices.map((v, i) => {\n            const isTrueUp = totals.trueUp > 0 && i === totals.trueUpMonth;\n            const trueUpShare = isTrueUp ? totals.trueUp / Math.max(v, 1) : 0;\n            return (\n              <div\n                className=\"relative flex h-full min-w-0 flex-1 cursor-default items-end\"\n                key={i}\n                onMouseEnter={() => setHover(i)}\n              >\n                <motion.div\n                  animate={{ height: v > 0 ? `${Math.max(6, (v / max) * 100)}%` : \"2px\" }}\n                  className={cn(\n                    \"relative w-full overflow-hidden rounded-[calc(var(--radius)*0.3)] transition-colors\",\n                    v > 0 ? \"bg-foreground/40\" : \"bg-foreground/12\",\n                    (hover === null ? i === 0 : hover === i) && v > 0 && \"bg-foreground\",\n                  )}\n                  initial={false}\n                  transition={spring}\n                >\n                  {isTrueUp && (\n                    <motion.div\n                      animate={{ height: `${trueUpShare * 100}%` }}\n                      className=\"absolute inset-x-0 top-0 bg-warning\"\n                      initial={false}\n                      transition={spring}\n                    />\n                  )}\n                </motion.div>\n              </div>\n            );\n          })}\n        </div>\n        <div className=\"mt-1.5 flex justify-between text-[11px] text-muted-foreground tabular-nums\">\n          <span>{tickFmt.format(phase.start)}</span>\n          {phase.months > 2 && <span>{tickFmt.format(addMonths(phase.start, Math.floor(phase.months / 2)))}</span>}\n          <span>{tickFmt.format(addMonths(phase.start, phase.months - 1))}</span>\n        </div>\n\n        <AnimatePresence initial={false}>\n          {totals.trueUp > 0 && (\n            <motion.p\n              animate={{ opacity: 1, height: \"auto\", marginTop: 10 }}\n              className=\"flex items-start gap-1.5 overflow-hidden text-warning-foreground text-xs\"\n              exit={{ opacity: 0, height: 0, marginTop: 0 }}\n              initial={reduce ? false : { opacity: 0, height: 0, marginTop: 0 }}\n              transition={spring}\n            >\n              <span className=\"mt-1 size-2 shrink-0 rounded-[calc(var(--radius)*0.2)] bg-warning\" />\n              <span>\n                {money.whole(totals.trueUp)} short of the {money.whole(phase.minimum.amount)} minimum, billed as a\n                true-up in {monthFmt.format(addMonths(phase.start, totals.trueUpMonth))}.\n              </span>\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n    </section>\n  );\n}\n"
    }
  ]
}