{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "sticky-cta",
  "title": "Sticky CTA",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-vue/blocks/sticky-cta/StickyCtaBar.vue",
      "content": "<script setup lang=\"ts\">\nimport { onBeforeUnmount, onMounted, ref } from 'vue'\nimport { Clock, X } from 'lucide-vue-next'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Separator } from '@/components/ui/separator'\n\nconst props = withDefaults(\n  defineProps<{\n    /** Scroll distance in pixels before the bar reveals. */\n    threshold?: number\n    /** sessionStorage key holding the dismissal, so it stays closed for the session. */\n    storageKey?: string\n  }>(),\n  {\n    threshold: 480,\n    storageKey: 'uipkge:sticky-cta-dismissed',\n  },\n)\n\n// Starts hidden so server and first client render agree — the scroll handler\n// is the only thing that reveals it.\nconst visible = ref(false)\nconst dismissed = ref(false)\n\nfunction onScroll() {\n  visible.value = !dismissed.value && window.scrollY > props.threshold\n}\n\nfunction dismiss() {\n  dismissed.value = true\n  visible.value = false\n  try {\n    window.sessionStorage.setItem(props.storageKey, '1')\n  } catch {\n    // Private browsing or blocked storage: dismissing still holds for this page view.\n  }\n}\n\nonMounted(() => {\n  try {\n    dismissed.value = window.sessionStorage.getItem(props.storageKey) === '1'\n  } catch {\n    dismissed.value = false\n  }\n  onScroll()\n  window.addEventListener('scroll', onScroll, { passive: true })\n})\n\nonBeforeUnmount(() => window.removeEventListener('scroll', onScroll))\n</script>\n\n<template>\n  <Transition\n    enter-active-class=\"transition duration-200 ease-out\"\n    enter-from-class=\"translate-y-full opacity-0\"\n    leave-active-class=\"transition duration-150 ease-in\"\n    leave-to-class=\"translate-y-full opacity-0\"\n  >\n    <div\n      v-show=\"visible\"\n      data-slot=\"sticky-cta-bar\"\n      class=\"fixed inset-x-0 bottom-0 z-50 px-4 pb-4\"\n      role=\"region\"\n      aria-label=\"Trial offer\"\n    >\n      <div\n        class=\"border-border bg-card/95 mx-auto flex max-w-4xl flex-wrap items-center gap-x-5 gap-y-3 rounded-xl border p-3 pl-4 shadow-lg backdrop-blur\"\n      >\n        <Badge variant=\"secondary\" class=\"gap-1.5\">\n          <Clock class=\"size-3\" aria-hidden=\"true\" />\n          Free for 14 days\n        </Badge>\n\n        <p class=\"min-w-0 grow text-sm\">\n          <span class=\"font-medium\">Start on the full plan.</span>\n          <span class=\"text-muted-foreground\"> No card, no sales call, cancel from the dashboard.</span>\n        </p>\n\n        <div class=\"flex items-center gap-2\">\n          <Button size=\"sm\">Start free trial</Button>\n          <Button size=\"sm\" variant=\"ghost\" class=\"hidden sm:inline-flex\">Talk to us</Button>\n          <Separator orientation=\"vertical\" class=\"hidden h-6 sm:block\" />\n          <Button size=\"icon\" variant=\"ghost\" aria-label=\"Dismiss this offer\" @click=\"dismiss\">\n            <X class=\"size-4\" aria-hidden=\"true\" />\n          </Button>\n        </div>\n      </div>\n    </div>\n  </Transition>\n</template>\n",
      "type": "registry:block",
      "target": "~/app/components/blocks/sticky-cta/StickyCtaBar.vue"
    },
    {
      "path": "packages/registry-vue/blocks/sticky-cta/StickyCtaCornerButton.vue",
      "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport { MessageSquare } from 'lucide-vue-next'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\n\n// Expands on focus as well as hover: a keyboard user should be able to read the\n// label before deciding whether to activate it.\nconst expanded = ref(false)\n</script>\n\n<template>\n  <div data-slot=\"sticky-cta-corner-button\" class=\"fixed right-5 bottom-5 z-50\">\n    <Button\n      size=\"lg\"\n      class=\"group h-12 gap-0 rounded-full pr-4 pl-4 shadow-lg\"\n      @mouseenter=\"expanded = true\"\n      @mouseleave=\"expanded = false\"\n      @focus=\"expanded = true\"\n      @blur=\"expanded = false\"\n    >\n      <MessageSquare class=\"size-5 shrink-0\" aria-hidden=\"true\" />\n      <!-- Width, not display: a hidden label would not animate, and a label that\n           pops in shifts the button under the cursor mid-click. -->\n      <span\n        class=\"overflow-hidden whitespace-nowrap transition-colors duration-200\"\n        :class=\"expanded ? 'ml-2 max-w-[10rem] opacity-100' : 'max-w-0 opacity-0'\"\n      >\n        Ask an engineer\n      </span>\n    </Button>\n\n    <Badge\n      variant=\"secondary\"\n      class=\"border-background pointer-events-none absolute -top-1 -right-1 size-5 justify-center rounded-full border-2 p-0 text-xs\"\n    >\n      1\n    </Badge>\n  </div>\n</template>\n",
      "type": "registry:block",
      "target": "~/app/components/blocks/sticky-cta/StickyCtaCornerButton.vue"
    },
    {
      "path": "packages/registry-vue/blocks/sticky-cta/StickyCtaReadingProgress.vue",
      "content": "<script setup lang=\"ts\">\nimport { onBeforeUnmount, onMounted, ref } from 'vue'\nimport { ArrowRight, X } from 'lucide-vue-next'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Progress } from '@/components/ui/progress'\nimport { Separator } from '@/components/ui/separator'\n\nconst props = withDefaults(defineProps<{ revealAt?: number }>(), { revealAt: 15 })\n\n// Both start at rest so server and first client paint agree; the scroll handler\n// is the only thing that reveals the bar or moves the progress.\nconst progress = ref(0)\nconst visible = ref(false)\nconst dismissed = ref(false)\n\nfunction onScroll() {\n  const scrollable = document.documentElement.scrollHeight - window.innerHeight\n  const percent = scrollable > 0 ? Math.min(Math.round((window.scrollY / scrollable) * 100), 100) : 0\n  progress.value = percent\n  visible.value = !dismissed.value && percent >= props.revealAt\n}\n\nonMounted(() => {\n  onScroll()\n  window.addEventListener('scroll', onScroll, { passive: true })\n  window.addEventListener('resize', onScroll)\n})\n\nonBeforeUnmount(() => {\n  window.removeEventListener('scroll', onScroll)\n  window.removeEventListener('resize', onScroll)\n})\n</script>\n\n<template>\n  <Transition\n    enter-active-class=\"transition duration-200 ease-out\"\n    enter-from-class=\"translate-y-full opacity-0\"\n    leave-active-class=\"transition duration-150 ease-in\"\n    leave-to-class=\"translate-y-full opacity-0\"\n  >\n    <div\n      v-show=\"visible\"\n      data-slot=\"sticky-cta-reading-progress\"\n      class=\"border-border bg-card/95 fixed inset-x-0 bottom-0 z-50 border-t backdrop-blur\"\n      role=\"region\"\n      aria-label=\"Reading progress and trial offer\"\n    >\n      <Progress :model-value=\"progress\" class=\"h-0.5 rounded-none\" aria-label=\"Reading progress\" />\n\n      <div class=\"mx-auto flex max-w-5xl flex-wrap items-center gap-x-5 gap-y-2 px-6 py-3\">\n        <Badge variant=\"secondary\" class=\"shrink-0 tabular-nums\">{{ progress }}% read</Badge>\n\n        <p class=\"min-w-0 grow text-sm\">\n          <span class=\"font-medium\">Finished the technical detail?</span>\n          <span class=\"text-muted-foreground\"> The trial runs on your real warehouse, read-only.</span>\n        </p>\n\n        <div class=\"flex shrink-0 items-center gap-2\">\n          <Button size=\"sm\">\n            Start free\n            <ArrowRight class=\"ml-1.5 size-3.5\" aria-hidden=\"true\" />\n          </Button>\n          <Separator orientation=\"vertical\" class=\"hidden h-5 sm:block\" />\n          <Button\n            size=\"icon\"\n            variant=\"ghost\"\n            class=\"size-7\"\n            aria-label=\"Dismiss\"\n            @click=\"((dismissed = true), (visible = false))\"\n          >\n            <X class=\"size-3.5\" aria-hidden=\"true\" />\n          </Button>\n        </div>\n      </div>\n    </div>\n  </Transition>\n</template>\n",
      "type": "registry:block",
      "target": "~/app/components/blocks/sticky-cta/StickyCtaReadingProgress.vue"
    }
  ],
  "dependencies": [
    "lucide-vue-next"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/vue/badge.json",
    "https://uipkge.dev/r/vue/button.json",
    "https://uipkge.dev/r/vue/progress.json",
    "https://uipkge.dev/r/vue/separator.json"
  ],
  "description": "Sticky conversion trio that stays out of the way until scrolled for: a bottom CTA bar with offer line and social proof, a compact corner button that expands on hover or focus, and a bottom bar that doubles as a reading-progress indicator.",
  "categories": [
    "marketing"
  ]
}