{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "desktop-4",
  "title": "Desktop 4: licence activation",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Licence activation for a paid desktop app: a trial meter, a key field that cleans up pasted keys, activation that shows why a key failed, the licensed view with deactivate, and a trial-ended mode.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/app-window.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/input.json",
    "https://ui.ballmac.com/r/progress.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/use-resource.json",
    "https://ui.ballmac.com/r/feel-presets.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/desktop-4/desktop-4.tsx",
      "content": "// Ballmac UI: Desktop 4. https://ui.ballmac.com/blocks/desktop-4\n\"use client\"\n\nimport * as React from \"react\"\nimport { BadgeCheck, CircleAlert, KeyRound, Loader2, Sparkles } from \"lucide-react\"\n\nimport { AppWindow, AppWindowContent, AppWindowTitleBar } from \"@/components/ballmac/app-window\"\nimport { Button, buttonVariants } from \"@/components/ballmac/button\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { Progress } from \"@/components/ballmac/progress\"\nimport { frameProps } from \"@/components/ballmac/state-view\"\nimport { useFeel, type FeelId } from \"@/lib/ballmac/feel\"\nimport { formPhase, type FormStateProps } from \"@/lib/ballmac/use-resource\"\nimport { cn } from \"@/lib/utils\"\n\ntype Desktop4Licence = {\n  /** Who the licence belongs to, usually the buyer's email. */\n  licensedTo: string\n  /** The plan name, for example \"Pro\". */\n  plan?: string\n}\n\ntype Desktop4Props = Omit<React.ComponentProps<\"div\">, \"children\" | \"onSubmit\"> &\n  FormStateProps & {\n    /** Which platform the window handles like. Defaults to the page's feel. */\n    feel?: FeelId\n    /** Draw the window frame. Turn off inside a real Tauri or Electron window that already has one. */\n    frame?: boolean\n    appName?: string\n    /** The app icon. A generated one is drawn when you leave it out. */\n    icon?: React.ReactNode\n    /** Days left in the trial. 0 means the trial has ended. */\n    trialDaysLeft?: number\n    /** The length of the trial in days, for the meter. */\n    trialLength?: number\n    /** Where \"Buy a licence\" goes. */\n    buyUrl?: string\n    /** One line about seats, for example \"One licence works on up to 3 Macs.\" */\n    seatsNote?: string\n    /** An active licence. Pass it and the window opens on the licensed view. */\n    licence?: Desktop4Licence\n    /**\n     * Called with the trimmed key. Resolve with the licence to show who it belongs to; throw an Error to show its message under the\n     * field. Defaults to a simulated check that accepts any key of 16 or more characters.\n     */\n    onActivate?: (key: string) => Desktop4Licence | void | Promise<Desktop4Licence | void>\n    /** Called by \"Continue trial\". Not shown once the trial has ended. */\n    onContinueTrial?: () => void\n    /** Called by \"Quit\" once the trial has ended. */\n    onQuit?: () => void\n    /** Called by \"Deactivate\" in the licensed view, to free the seat. */\n    onDeactivate?: () => void | Promise<void>\n    /** Called by Done in the licensed view, and by the close button. */\n    onDone?: () => void\n  }\n\nfunction AppIcon({ icon, className }: { icon?: React.ReactNode; className?: string }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"from-primary to-primary/70 text-primary-foreground flex shrink-0 items-center justify-center rounded-[22%] bg-linear-to-b shadow-[0_1px_2px_rgb(0_0_0/0.2),inset_0_1px_0_rgb(255_255_255/0.25)] [&_svg]:size-1/2\",\n        className\n      )}\n    >\n      {icon ?? <Sparkles />}\n    </span>\n  )\n}\n\nfunction Actions({ feel, start, children }: { feel: FeelId; start?: React.ReactNode; children: React.ReactNode[] }) {\n  const ordered = feel === \"windows\" ? [...children].reverse() : children\n  return (\n    <div data-slot=\"desktop-4-actions\" className=\"flex flex-wrap items-center gap-2\">\n      {start}\n      <div className=\"ms-auto flex flex-wrap justify-end gap-2\">{ordered}</div>\n    </div>\n  )\n}\n\n/**\n * Licence activation for a paid desktop app: the trial meter, a key field that accepts a pasted key with spaces or line breaks,\n * activation with the reason when it fails, and the licensed view with deactivate. Wording follows the platform (\"this Mac\",\n * \"this PC\") and its button order.\n */\nfunction Desktop4({\n  feel: feelProp,\n  frame = true,\n  appName = \"Acme\",\n  icon,\n  trialDaysLeft = 9,\n  trialLength = 14,\n  buyUrl = \"#\",\n  seatsNote,\n  licence: licenceProp,\n  onActivate,\n  onContinueTrial,\n  onQuit,\n  onDeactivate,\n  onDone,\n  state: stateProp,\n  errorState,\n  className,\n  ...props\n}: Desktop4Props) {\n  const page = useFeel().feel\n  const feel = feelProp ?? page\n  const win = feel === \"windows\"\n  const mac = feel === \"macos\"\n  const device = mac ? \"this Mac\" : win ? \"this PC\" : \"this device\"\n  const id = React.useId()\n  const [key, setKey] = React.useState(\"\")\n  const [licence, setLicence] = React.useState<Desktop4Licence | undefined>(licenceProp)\n  React.useEffect(() => setLicence(licenceProp), [licenceProp])\n  const [phase, setPhase] = React.useState<\"idle\" | \"sending\" | \"error\" | \"done\">(licenceProp ? \"done\" : \"idle\")\n  const [message, setMessage] = React.useState<string>()\n  const forced = formPhase(stateProp)\n  const current = forced ?? phase\n  const expired = trialDaysLeft <= 0\n  const t = (macText: string, other: string) => (mac ? macText : other)\n\n  async function activate(e?: React.FormEvent) {\n    e?.preventDefault()\n    const clean = key.replace(/\\s+/g, \"\")\n    if (!clean) {\n      setPhase(\"error\")\n      setMessage(\"Enter the licence key from your receipt email.\")\n      return\n    }\n    setPhase(\"sending\")\n    setMessage(undefined)\n    try {\n      let result: Desktop4Licence | void\n      if (onActivate) result = await onActivate(clean)\n      else {\n        await new Promise((r) => setTimeout(r, 900))\n        if (clean.length < 16) throw new Error(\"That key is not valid. Check that you copied all of it.\")\n        result = { licensedTo: \"you@example.com\", plan: \"Pro\" }\n      }\n      setLicence(result || { licensedTo: \"\", plan: undefined })\n      setPhase(\"done\")\n    } catch (err) {\n      setPhase(\"error\")\n      setMessage(err instanceof Error && err.message ? err.message : \"The key could not be checked. Try again in a moment.\")\n    }\n  }\n\n  const done = current === \"done\"\n  const sending = current === \"sending\"\n  const errorText = current === \"error\" ? (forced === \"error\" ? (errorState?.description ?? \"This key has reached its device limit. Deactivate it on another device, then try again.\") : message) : undefined\n\n  const content = done ? (\n    <div className=\"flex flex-1 flex-col gap-5 p-6\">\n      <div className={cn(\"flex gap-4\", mac ? \"flex-col items-center text-center\" : \"items-center\")}>\n        <AppIcon icon={icon} className={mac ? \"size-16\" : \"size-12\"} />\n        <div aria-live=\"polite\">\n          <h2 id={`${id}-title`} className=\"flex items-center gap-2 text-lg font-semibold\">\n            <BadgeCheck aria-hidden=\"true\" className=\"text-chart-2 size-5\" />\n            {appName} {licence?.plan ?? \"Pro\"} is activated\n          </h2>\n          <p className=\"text-muted-foreground mt-1 text-[13px]\">\n            {licence?.licensedTo ? <>Licensed to <span className=\"text-foreground font-medium break-all\">{licence.licensedTo}</span> on {device}.</> : <>Activated on {device}. Thank you for your support.</>}\n          </p>\n        </div>\n      </div>\n      <div className=\"flex-1\" />\n      <Actions\n        feel={feel}\n        start={\n          onDeactivate ? (\n            <Button variant=\"link\" size=\"sm\" className=\"px-0\" onClick={async () => { await onDeactivate(); setLicence(undefined); setPhase(\"idle\"); setKey(\"\") }}>\n              {t(\"Deactivate This Mac\", `Deactivate ${device}`)}\n            </Button>\n          ) : null\n        }\n      >\n        {[\n          <Button key=\"done\" size=\"sm\" onClick={onDone}>\n            Done\n          </Button>,\n        ]}\n      </Actions>\n    </div>\n  ) : (\n    <form onSubmit={activate} noValidate className=\"flex flex-1 flex-col gap-5 p-6\">\n      <div className={cn(\"flex gap-4\", mac ? \"flex-col items-center text-center\" : \"items-start\")}>\n        <AppIcon icon={icon} className={mac ? \"size-16\" : \"size-12\"} />\n        <div className=\"min-w-0\">\n          <h2 id={`${id}-title`} className=\"text-lg font-semibold text-pretty\">\n            {expired ? \"Your free trial has ended\" : `Activate ${appName}`}\n          </h2>\n          <p className=\"text-muted-foreground mt-1 text-[13px] text-pretty\">\n            {expired\n              ? `Enter a licence key to keep using ${appName}. Your data is safe and waiting.`\n              : `Enter the licence key from your receipt to unlock ${appName} on ${device}.`}\n          </p>\n        </div>\n      </div>\n\n      {!expired ? (\n        <div className=\"grid gap-1.5\">\n          <div className=\"flex justify-between text-xs\">\n            <span className=\"text-muted-foreground\">Free trial</span>\n            <span className=\"font-medium tabular-nums\">{trialDaysLeft === 1 ? \"1 day left\" : `${trialDaysLeft} days left`}</span>\n          </div>\n          <Progress value={(trialDaysLeft / trialLength) * 100} aria-label={`Free trial: ${trialDaysLeft} of ${trialLength} days left`} />\n        </div>\n      ) : null}\n\n      <div className=\"grid gap-1.5\">\n        <label htmlFor={`${id}-key`} className=\"text-sm font-medium\">\n          Licence key\n        </label>\n        <Input\n          id={`${id}-key`}\n          dir=\"ltr\"\n          value={key}\n          onChange={(e) => {\n            setKey(e.target.value)\n            if (phase === \"error\") setPhase(\"idle\")\n          }}\n          placeholder=\"XXXX-XXXX-XXXX-XXXX\"\n          autoComplete=\"off\"\n          autoCapitalize=\"characters\"\n          spellCheck={false}\n          readOnly={sending}\n          aria-invalid={!!errorText || undefined}\n          aria-describedby={errorText ? `${id}-error` : seatsNote ? `${id}-seats` : undefined}\n          className=\"font-mono tracking-wider\"\n        />\n        {errorText ? (\n          <p id={`${id}-error`} role=\"alert\" className=\"text-destructive flex items-start gap-1.5 text-[13px]\">\n            <CircleAlert aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\" />\n            {errorText}\n          </p>\n        ) : seatsNote ? (\n          <p id={`${id}-seats`} className=\"text-muted-foreground text-xs\">\n            {seatsNote}\n          </p>\n        ) : null}\n      </div>\n\n      <div className=\"flex-1\" />\n      <Actions\n        feel={feel}\n        start={\n          <a href={buyUrl} target=\"_blank\" rel=\"noreferrer\" className={cn(buttonVariants({ variant: \"link\", size: \"sm\" }), \"px-0\")}>\n            {t(\"Buy a Licence…\", \"Buy a licence\")}\n          </a>\n        }\n      >\n        {[\n          expired ? (\n            <Button key=\"quit\" type=\"button\" variant=\"outline\" size=\"sm\" onClick={onQuit}>\n              {t(`Quit ${appName}`, \"Quit\")}\n            </Button>\n          ) : (\n            <Button key=\"trial\" type=\"button\" variant=\"outline\" size=\"sm\" onClick={onContinueTrial} disabled={sending}>\n              {t(\"Continue Trial\", \"Continue trial\")}\n            </Button>\n          ),\n          <Button key=\"activate\" type=\"submit\" size=\"sm\" disabled={sending} aria-disabled={sending || undefined}>\n            {sending ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : <KeyRound aria-hidden=\"true\" />}\n            {sending ? \"Activating…\" : \"Activate\"}\n          </Button>,\n        ]}\n      </Actions>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {sending ? \"Checking the licence key\" : \"\"}\n      </p>\n    </form>\n  )\n\n  if (!frame)\n    return (\n      <div data-slot=\"desktop-4\" data-state={done ? \"success\" : current === \"sending\" ? \"submitting\" : current === \"error\" ? \"error\" : \"ready\"} data-feel={feelProp} role=\"region\" aria-labelledby={`${id}-title`} className={cn(\"flex flex-col\", className)} {...frameProps(props)}>\n        {content}\n      </div>\n    )\n  return (\n    <AppWindow\n      data-slot=\"desktop-4\"\n      data-state={done ? \"success\" : current === \"sending\" ? \"submitting\" : current === \"error\" ? \"error\" : \"ready\"}\n      feel={feel}\n      onClose={onDone}\n      role=\"region\"\n      aria-labelledby={`${id}-title`}\n      className={cn(\"h-[30rem] w-full max-w-md\", className)}\n      {...frameProps(props)}\n    >\n      <AppWindowTitleBar title={win ? `${appName} licence` : \"\"} icon={<KeyRound />} className=\"border-b-0\" />\n      <AppWindowContent className=\"flex flex-col\">{content}</AppWindowContent>\n    </AppWindow>\n  )\n}\n\nexport { Desktop4, type Desktop4Props, type Desktop4Licence }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/desktop-4/desktop-4.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "desktop",
      "licence",
      "license",
      "activation",
      "trial",
      "paywall",
      "license key",
      "tauri",
      "electron",
      "macos",
      "windows",
      "feel"
    ],
    "version": "1.0.0",
    "updated": "2026-10-17",
    "tests": true,
    "ai": {
      "summary": "A licence window. <Desktop4 appName trialDaysLeft trialLength buyUrl onActivate={async (key) => { const r = await validate(key); if (!r.ok) throw new Error(r.reason); return { licensedTo: r.email, plan: 'Pro' } }} onContinueTrial onDeactivate />. trialDaysLeft={0} switches to the trial-ended mode with Quit instead of Continue. Pass licence to open on the licensed view.",
      "whenToUse": [
        "Activating a paid Tauri or Electron app with a licence key (Dodo Payments, Lemon Squeezy, Keygen, Paddle)",
        "The window shown when a trial ends"
      ],
      "whenNotToUse": [
        "Web subscriptions with sign-in (use billing-1 or pricing blocks)",
        "Entering a one-time code (use verify-1 or input-otp)"
      ],
      "composesWith": [
        "desktop-1",
        "desktop-6",
        "app-window",
        "feel-auto"
      ],
      "a11y": [
        {
          "keys": "Enter in the key field",
          "action": "Activates; the field is labelled and its error is linked with aria-describedby"
        },
        {
          "keys": "Failure",
          "action": "The reason is announced as an alert and the field is marked invalid"
        },
        {
          "keys": "Success",
          "action": "The activated heading is announced politely"
        }
      ],
      "customization": [
        "feel: 'macos' | 'windows' (defaults to the page's feel); wording follows it (\"this Mac\", \"this PC\") and macOS uses Title Case buttons",
        "seatsNote: one line under the field, e.g. \"One licence works on up to 3 Macs.\"",
        "Dodo Payments: call /licenses/activate from your backend or Tauri command with the key and a device name, then store the instance id for deactivate",
        "state: \"submitting\" | \"success\" | \"error\" to drive it yourself; leave it out and it runs itself"
      ]
    },
    "states": [
      {
        "id": "submitting",
        "title": "Activating",
        "example": "desktop-4-activating",
        "trigger": "state=\"submitting\"",
        "note": "The field goes read-only and Activate shows a spinner and \"Activating…\"; announced politely."
      },
      {
        "id": "success",
        "title": "Activated",
        "example": "desktop-4-activated",
        "trigger": "state=\"success\" (or pass licence)",
        "note": "Who it is licensed to and on which device, with Done and, when onDeactivate is set, Deactivate."
      },
      {
        "id": "error",
        "title": "Key refused",
        "example": "desktop-4-error",
        "trigger": "state=\"error\"",
        "note": "The reason under the field, announced as an alert and linked to it; errorState.description sets it. Thrown Error messages from onActivate show the same way."
      }
    ],
    "examples": [
      "desktop-4-demo",
      "desktop-4-windows",
      "desktop-4-expired",
      "desktop-4-activating",
      "desktop-4-activated",
      "desktop-4-error"
    ],
    "url": "https://ui.ballmac.com/components/desktop-4"
  },
  "categories": [
    "blocks",
    "desktop"
  ],
  "type": "registry:block"
}