@stackonward/cli 1.5.3 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/index.js.map +1 -1
  3. package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +112 -56
  4. package/dist/resources/components/nuxt/site-source/app/assets/icons/brand/android.svg +10 -0
  5. package/dist/resources/components/nuxt/site-source/app/assets/icons/brand/apple.svg +3 -0
  6. package/dist/resources/components/nuxt/site-source/app/assets/icons/brand/whatsapp.svg +5 -0
  7. package/dist/resources/components/nuxt/site-source/app/components/app/Sidebar.vue +1 -1
  8. package/dist/resources/components/nuxt/site-source/app/components/checkout/Status.vue +3 -3
  9. package/dist/resources/components/nuxt/site-source/app/components/consent/Banner.vue +23 -8
  10. package/dist/resources/components/nuxt/site-source/app/components/content/CallToAction.vue +59 -0
  11. package/dist/resources/components/nuxt/site-source/app/components/content/Card.vue +35 -0
  12. package/dist/resources/components/nuxt/site-source/app/components/content/Compatibility.vue +10 -0
  13. package/dist/resources/components/nuxt/site-source/app/components/content/Faq.vue +56 -0
  14. package/dist/resources/components/nuxt/site-source/app/components/content/Features.vue +8 -0
  15. package/dist/resources/components/nuxt/site-source/app/components/content/List.vue +1 -8
  16. package/dist/resources/components/nuxt/site-source/app/components/content/Reviews.vue +93 -0
  17. package/dist/resources/components/nuxt/site-source/app/components/countdown/Timer.vue +112 -0
  18. package/dist/resources/components/nuxt/site-source/app/components/locale/Menu.vue +3 -3
  19. package/dist/resources/components/nuxt/site-source/app/components/site/BackToTop.vue +30 -23
  20. package/dist/resources/components/nuxt/site-source/app/components/site/Header.vue +2 -2
  21. package/dist/resources/components/nuxt/site-source/app/components/steps/Progress.vue +36 -0
  22. package/dist/resources/components/nuxt/site-source/app/components/steps/Showcase.vue +340 -0
  23. package/dist/resources/components/nuxt/site-source/app/components/theme/Menu.vue +4 -4
  24. package/dist/resources/components/nuxt/site-source/app/components/ui/Button.vue +28 -2
  25. package/dist/resources/components/nuxt/site-source/app/components/ui/GradientBorder.vue +5 -5
  26. package/dist/resources/components/nuxt/site-source/app/components/ui/Icon.vue +14 -0
  27. package/dist/resources/components/nuxt/site-source/app/components/ui/Image.vue +126 -0
  28. package/dist/resources/components/nuxt/site-source/app/components/ui/Marquee.vue +99 -0
  29. package/dist/resources/components/nuxt/site-source/app/components/ui/Modal.vue +26 -88
  30. package/dist/resources/components/nuxt/site-source/app/components/ui/Popover.vue +87 -93
  31. package/dist/resources/components/nuxt/site-source/app/components/ui/Text.vue +84 -12
  32. package/dist/resources/components/nuxt/site-source/app/components/ui/Video.vue +105 -0
  33. package/dist/resources/components/nuxt/site-source/app/components/whatsapp/Chat.vue +126 -0
  34. package/dist/resources/components/nuxt/site-source/app/components/youtube/Embed.vue +286 -73
  35. package/dist/resources/components/nuxt/site-source/app/composables/useFloatingBottomStack.ts +44 -0
  36. package/dist/resources/components/nuxt/site-source/app/composables/useNavigation.ts +6 -8
  37. package/dist/resources/components/nuxt/site-source/app/consent/types.ts +1 -4
  38. package/dist/resources/components/nuxt/site-source/app/content/types.ts +3 -0
  39. package/dist/resources/components/nuxt/site-source/app/theme/accordion.ts +5 -4
  40. package/dist/resources/components/nuxt/site-source/app/theme/breadcrumb.ts +14 -4
  41. package/dist/resources/components/nuxt/site-source/app/theme/button.ts +138 -28
  42. package/dist/resources/components/nuxt/site-source/app/theme/carousel.ts +11 -7
  43. package/dist/resources/components/nuxt/site-source/app/theme/checkbox.ts +9 -3
  44. package/dist/resources/components/nuxt/site-source/app/theme/drawer.ts +6 -0
  45. package/dist/resources/components/nuxt/site-source/app/theme/form-field.ts +8 -0
  46. package/dist/resources/components/nuxt/site-source/app/theme/icon.ts +6 -0
  47. package/dist/resources/components/nuxt/site-source/app/theme/index.ts +8 -1
  48. package/dist/resources/components/nuxt/site-source/app/theme/input.ts +34 -18
  49. package/dist/resources/components/nuxt/site-source/app/theme/modal.ts +9 -5
  50. package/dist/resources/components/nuxt/site-source/app/theme/pagination.ts +9 -2
  51. package/dist/resources/components/nuxt/site-source/app/theme/radio-group.ts +7 -0
  52. package/dist/resources/components/nuxt/site-source/app/theme/select.ts +19 -7
  53. package/dist/resources/components/nuxt/site-source/app/theme/stepper.ts +15 -6
  54. package/dist/resources/components/nuxt/site-source/app/theme/textarea.ts +6 -0
  55. package/dist/resources/components/nuxt/site-source/app/theme/toast.ts +1 -4
  56. package/dist/resources/components/nuxt/site-source/app/theme/tooltip.ts +5 -1
  57. package/dist/resources/components/registry.json +133 -45
  58. package/dist/resources/components/vue/ui/BottomSheet.vue +5 -1
  59. package/dist/resources/components/vue/ui/ScrollArea.vue +2 -1
  60. package/dist/resources/registry.json +1 -1
  61. package/dist/resources/templates/base/.claude/docs/architecture-template.md +2 -1
  62. package/dist/resources/templates/base/AGENTS.md +7 -0
  63. package/dist/resources/templates/base/CLAUDE.md +7 -0
  64. package/dist/resources/templates/nuxt/.claude/docs/stack.md +14 -1
  65. package/dist/resources/templates/nuxt/DESIGN.md +146 -0
  66. package/dist/resources/templates/nuxt/app/app.config.ts +2 -0
  67. package/dist/resources/templates/nuxt/app/pages/app/index.vue +9 -7
  68. package/dist/resources/templates/nuxt/app/pages/index.vue +12 -27
  69. package/dist/resources/templates/nuxt/app/pages/ui/index.vue +219 -3
  70. package/dist/resources/templates/nuxt/nuxt.config.ts +2 -4
  71. package/dist/resources/templates/nuxt/package.json +2 -1
  72. package/dist/resources/templates/nuxt/stylelint.config.mjs +12 -0
  73. package/package.json +3 -3
  74. package/dist/resources/components/nuxt/site-source/app/assets/icons/common/.gitkeep +0 -0
@@ -1,11 +1,17 @@
1
1
  <script setup lang="ts">
2
+ import type { PopoverProps } from "@nuxt/ui";
3
+
2
4
  interface PopoverAction {
3
5
  label: string;
4
6
  icon?: string;
5
7
  disabled?: boolean;
6
8
  value?: string;
9
+ to?: string;
10
+ target?: string;
11
+ data?: unknown;
7
12
  }
8
13
 
14
+ defineOptions({ inheritAttrs: false });
9
15
  const props = withDefaults(
10
16
  defineProps<{
11
17
  modelValue?: boolean;
@@ -13,24 +19,40 @@ const props = withDefaults(
13
19
  title?: string;
14
20
  description?: string;
15
21
  actions?: PopoverAction[];
16
- ui?: Record<string, unknown>;
22
+ ui?: PopoverProps["ui"];
23
+ mode?: "click" | "hover" | "manual";
24
+ content?: PopoverProps["content"];
25
+ portal?: PopoverProps["portal"];
26
+ arrow?: boolean;
27
+ dismissible?: boolean;
28
+ closeOnSelect?: boolean;
29
+ closeOnScroll?: boolean;
30
+ overlay?: boolean;
31
+ theme?: "light" | "dark";
32
+ openDelay?: number;
33
+ closeDelay?: number;
17
34
  }>(),
18
35
  {
19
36
  modelValue: undefined,
20
37
  open: undefined,
21
38
  actions: () => [],
22
39
  ui: () => ({}),
40
+ mode: "click",
41
+ arrow: false,
42
+ dismissible: true,
43
+ closeOnSelect: true,
44
+ closeOnScroll: false,
45
+ overlay: false,
46
+ openDelay: 0,
47
+ closeDelay: 300,
23
48
  },
24
49
  );
25
-
26
50
  const emit = defineEmits<{
27
51
  "update:modelValue": [value: boolean];
28
52
  "update:open": [value: boolean];
29
53
  select: [action: PopoverAction, index: number];
30
54
  }>();
31
-
32
55
  const internalOpen = ref(false);
33
-
34
56
  const isOpen = computed({
35
57
  get: () => props.open ?? props.modelValue ?? internalOpen.value,
36
58
  set: (value: boolean) => {
@@ -39,112 +61,84 @@ const isOpen = computed({
39
61
  emit("update:modelValue", value);
40
62
  },
41
63
  });
42
-
43
64
  function selectAction(action: PopoverAction, index: number) {
44
- if (action.disabled) {
45
- return;
46
- }
47
65
  emit("select", action, index);
48
- isOpen.value = false;
66
+ if (props.closeOnSelect) isOpen.value = false;
67
+ }
68
+ function updateOpen(value: boolean) {
69
+ if (props.mode !== "manual") isOpen.value = value;
49
70
  }
71
+ onMounted(() => {
72
+ useEventListener(
73
+ window,
74
+ "scroll",
75
+ () => {
76
+ if (props.closeOnScroll) isOpen.value = false;
77
+ },
78
+ { capture: true, passive: true },
79
+ );
80
+ });
50
81
  </script>
51
82
 
52
83
  <template>
53
- <UPopover v-bind="$attrs" v-model:open="isOpen" :ui="ui">
54
- <slot name="reference">
55
- <slot name="trigger" />
56
- </slot>
57
-
84
+ <Teleport to="body">
85
+ <div
86
+ v-if="overlay && isOpen"
87
+ class="fixed inset-0 z-40 bg-neutral/30"
88
+ aria-hidden="true"
89
+ @click="dismissible && (isOpen = false)"
90
+ />
91
+ </Teleport>
92
+ <UPopover
93
+ v-bind="$attrs"
94
+ :open="isOpen"
95
+ :mode="mode === 'hover' ? 'hover' : 'click'"
96
+ :content="content"
97
+ :portal="portal"
98
+ :arrow="arrow"
99
+ :dismissible="dismissible"
100
+ :open-delay="openDelay"
101
+ :close-delay="closeDelay"
102
+ :ui="{
103
+ ...ui,
104
+ content:
105
+ typeof ui.content === 'function'
106
+ ? ui.content
107
+ : [
108
+ 'z-50 rounded-lg border-0 bg-surface ring-0 shadow-[0_2px_12px_rgb(0_0_0/10%)]',
109
+ theme,
110
+ ui.content,
111
+ ],
112
+ }"
113
+ @update:open="updateOpen"
114
+ >
115
+ <slot name="reference"><slot name="trigger" /></slot>
58
116
  <template #content>
59
- <div class="base-popover-panel">
60
- <header v-if="title || description" class="base-popover-panel__header">
61
- <strong v-if="title">{{ title }}</strong>
62
- <p v-if="description">{{ description }}</p>
117
+ <div class="min-w-[180px] max-w-[min(360px,calc(100vw-32px))] text-text">
118
+ <header v-if="title || description" class="px-4 pt-4 pb-2">
119
+ <strong v-if="title" class="text-base">{{ title }}</strong>
120
+ <p v-if="description" class="mt-1 text-sm text-muted">{{ description }}</p>
63
121
  </header>
64
- <div v-if="$slots.default" class="base-popover-panel__body">
122
+ <div v-if="$slots.default" class="px-7.5 py-1 text-sm leading-[21px] max-md:text-base">
65
123
  <slot />
66
124
  </div>
67
- <div v-if="actions.length" class="base-popover-panel__actions">
68
- <button
125
+ <div v-if="actions.length" class="grid py-1">
126
+ <UiButton
69
127
  v-for="(action, index) in actions"
70
128
  :key="action.value || action.label"
71
- type="button"
72
- class="base-popover-panel__action"
129
+ color="neutral"
130
+ variant="ghost"
131
+ size="sm"
132
+ :to="action.to"
133
+ :target="action.target"
73
134
  :disabled="action.disabled"
135
+ :icon="action.icon"
136
+ class="h-auto min-h-[34px] justify-start rounded-none px-7.5 py-1 text-sm"
74
137
  @click="selectAction(action, index)"
138
+ >{{ action.label }}</UiButton
75
139
  >
76
- <UIcon v-if="action.icon" :name="action.icon" class="base-popover-panel__icon" />
77
- <span>{{ action.label }}</span>
78
- </button>
79
140
  </div>
80
141
  </div>
81
142
  </template>
82
143
  </UPopover>
83
144
  </template>
84
-
85
- <style scoped>
86
- .base-popover-panel {
87
- display: grid;
88
- min-width: 220px;
89
- max-width: min(320px, calc(100vw - 32px));
90
- overflow: hidden;
91
- border: 1px solid var(--site-color-border);
92
- border-radius: var(--site-radius-lg);
93
- background: var(--site-color-surface);
94
- box-shadow: var(--site-shadow-soft);
95
- }
96
-
97
- .base-popover-panel__header,
98
- .base-popover-panel__body {
99
- padding: 12px;
100
- }
101
-
102
- .base-popover-panel__header {
103
- display: grid;
104
- gap: 4px;
105
- border-bottom: 1px solid var(--site-color-border);
106
- }
107
-
108
- .base-popover-panel__header p {
109
- margin: 0;
110
- color: var(--site-color-muted);
111
- font-size: 13px;
112
- line-height: 1.5;
113
- }
114
-
115
- .base-popover-panel__actions {
116
- display: grid;
117
- gap: 2px;
118
- padding: 6px;
119
- }
120
-
121
- .base-popover-panel__action {
122
- display: flex;
123
- min-height: 38px;
124
- width: 100%;
125
- align-items: center;
126
- gap: 8px;
127
- border: 0;
128
- border-radius: var(--site-radius-md);
129
- background: transparent;
130
- color: var(--site-color-text);
131
- cursor: pointer;
132
- padding: 0 10px;
133
- text-align: left;
134
- }
135
-
136
- .base-popover-panel__action:hover {
137
- background: var(--site-color-surface-muted);
138
- }
139
-
140
- .base-popover-panel__action:disabled {
141
- cursor: not-allowed;
142
- opacity: 0.55;
143
- }
144
-
145
- .base-popover-panel__icon {
146
- width: 16px;
147
- height: 16px;
148
- color: var(--site-color-muted);
149
- }
150
- </style>
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ defineOptions({ inheritAttrs: false });
2
3
  type TextTone = "default" | "muted" | "dimmed" | "primary" | "success" | "warning" | "danger";
3
4
  type TextSize = "xs" | "sm" | "md" | "lg" | "xl";
4
5
  type TextWeight = "regular" | "medium" | "semibold" | "bold";
@@ -11,6 +12,10 @@ const props = withDefaults(
11
12
  weight?: TextWeight;
12
13
  truncate?: boolean;
13
14
  lineClamp?: number;
15
+ autoShrink?: boolean;
16
+ minFontSize?: number;
17
+ fontSizeStep?: number;
18
+ tooltip?: boolean;
14
19
  }>(),
15
20
  {
16
21
  as: "span",
@@ -19,6 +24,10 @@ const props = withDefaults(
19
24
  weight: "regular",
20
25
  truncate: false,
21
26
  lineClamp: undefined,
27
+ autoShrink: false,
28
+ minFontSize: 10,
29
+ fontSizeStep: 1,
30
+ tooltip: true,
22
31
  },
23
32
  );
24
33
 
@@ -28,24 +37,87 @@ const classes = computed(() => [
28
37
  `base-text--size-${props.size}`,
29
38
  `base-text--weight-${props.weight}`,
30
39
  {
31
- "base-text--truncate": props.truncate,
40
+ "base-text--truncate": props.truncate || (props.autoShrink && !props.lineClamp),
32
41
  "base-text--clamp": props.lineClamp,
33
42
  },
34
43
  ]);
35
44
 
36
- const style = computed(() =>
37
- props.lineClamp
38
- ? {
39
- "--base-text-line-clamp": String(props.lineClamp),
40
- }
41
- : undefined,
45
+ const element = ref<HTMLElement | null>(null);
46
+ const fontSize = ref<number>();
47
+ const overflowing = ref(false);
48
+ const text = ref("");
49
+ let measurement = 0;
50
+ const style = computed(() => ({
51
+ "--base-text-line-clamp": String(props.lineClamp ?? 1),
52
+ fontSize: fontSize.value ? `${fontSize.value}px` : undefined,
53
+ }));
54
+ async function measure() {
55
+ const run = ++measurement;
56
+ fontSize.value = undefined;
57
+ await nextTick();
58
+ const node = element.value;
59
+ if (!node || run !== measurement) return;
60
+ text.value = node.textContent?.trim() ?? "";
61
+ const exceeds = () =>
62
+ node.scrollWidth > node.clientWidth + 1 || node.scrollHeight > node.clientHeight + 1;
63
+ if (props.autoShrink) {
64
+ let size = Number.parseFloat(getComputedStyle(node).fontSize);
65
+ while (exceeds() && size > props.minFontSize) {
66
+ size = Math.max(props.minFontSize, size - Math.max(0.25, props.fontSizeStep));
67
+ fontSize.value = size;
68
+ await nextTick();
69
+ if (run !== measurement) return;
70
+ }
71
+ }
72
+ overflowing.value = exceeds();
73
+ }
74
+ let previousWidth = 0;
75
+ useResizeObserver(element, ([entry]) => {
76
+ if (entry && entry.contentRect.width !== previousWidth) {
77
+ previousWidth = entry.contentRect.width;
78
+ void measure();
79
+ }
80
+ });
81
+ useMutationObserver(
82
+ element,
83
+ () => {
84
+ void measure();
85
+ },
86
+ { childList: true, subtree: true, characterData: true },
87
+ );
88
+ watch(
89
+ () => [
90
+ props.autoShrink,
91
+ props.lineClamp,
92
+ props.truncate,
93
+ props.minFontSize,
94
+ props.fontSizeStep,
95
+ props.size,
96
+ ],
97
+ () => {
98
+ void measure();
99
+ },
42
100
  );
101
+ onMounted(() => {
102
+ void measure();
103
+ });
104
+ onBeforeUnmount(() => {
105
+ measurement++;
106
+ });
43
107
  </script>
44
108
 
45
109
  <template>
46
- <component :is="as" :class="classes" :style="style">
47
- <slot />
48
- </component>
110
+ <UTooltip :text="text" :disabled="!tooltip || !overflowing">
111
+ <component
112
+ :is="as"
113
+ ref="element"
114
+ v-bind="$attrs"
115
+ :class="classes"
116
+ :style="style"
117
+ :tabindex="tooltip && overflowing ? 0 : undefined"
118
+ ><slot
119
+ /></component>
120
+ </UTooltip>
49
121
  </template>
50
122
 
51
123
  <style scoped>
@@ -62,7 +134,7 @@ const style = computed(() =>
62
134
  }
63
135
 
64
136
  .base-text--tone-dimmed {
65
- color: #94a3b8;
137
+ color: var(--site-color-dimmed);
66
138
  }
67
139
 
68
140
  .base-text--tone-primary {
@@ -70,7 +142,7 @@ const style = computed(() =>
70
142
  }
71
143
 
72
144
  .base-text--tone-success {
73
- color: #16a34a;
145
+ color: var(--site-color-success);
74
146
  }
75
147
 
76
148
  .base-text--tone-warning {
@@ -0,0 +1,105 @@
1
+ <script setup lang="ts">
2
+ defineOptions({ inheritAttrs: false });
3
+ const props = withDefaults(
4
+ defineProps<{
5
+ src: string;
6
+ poster?: string;
7
+ preload?: "none" | "metadata" | "auto";
8
+ muted?: boolean;
9
+ autoplay?: boolean;
10
+ loop?: boolean;
11
+ controls?: boolean;
12
+ viewportPreload?: boolean;
13
+ playLabel?: string;
14
+ pauseLabel?: string;
15
+ }>(),
16
+ {
17
+ muted: true,
18
+ autoplay: true,
19
+ loop: true,
20
+ controls: true,
21
+ viewportPreload: false,
22
+ playLabel: "Play video",
23
+ pauseLabel: "Pause video",
24
+ },
25
+ );
26
+ const emit = defineEmits<{
27
+ ended: [];
28
+ timeUpdate: [time: number];
29
+ play: [];
30
+ pause: [];
31
+ error: [error: unknown];
32
+ }>();
33
+ const video = ref<HTMLVideoElement | null>(null);
34
+ const playing = ref(false);
35
+ const visible = ref(false);
36
+ useIntersectionObserver(
37
+ video,
38
+ (entries) => {
39
+ visible.value = entries.some((entry) => entry.isIntersecting);
40
+ },
41
+ { rootMargin: "200px" },
42
+ );
43
+ const preload = computed(
44
+ () => props.preload ?? (visible.value ? (props.viewportPreload ? "auto" : "metadata") : "none"),
45
+ );
46
+ async function play() {
47
+ try {
48
+ await video.value?.play();
49
+ } catch (error) {
50
+ emit("error", error);
51
+ }
52
+ }
53
+ function pause() {
54
+ video.value?.pause();
55
+ }
56
+ function toggle() {
57
+ if (playing.value) pause();
58
+ else void play();
59
+ }
60
+ defineExpose({ play, pause, toggle });
61
+ </script>
62
+
63
+ <template>
64
+ <div class="relative size-full">
65
+ <video
66
+ ref="video"
67
+ v-bind="$attrs"
68
+ class="size-full rounded-2ml object-cover"
69
+ playsinline
70
+ :src="src"
71
+ :poster="poster"
72
+ :preload="preload"
73
+ :muted="muted"
74
+ :autoplay="autoplay"
75
+ :loop="loop"
76
+ :controls="controls"
77
+ @play="
78
+ playing = true;
79
+ emit('play');
80
+ "
81
+ @pause="
82
+ playing = false;
83
+ emit('pause');
84
+ "
85
+ @ended="
86
+ playing = false;
87
+ emit('ended');
88
+ "
89
+ @timeupdate="emit('timeUpdate', video?.currentTime ?? 0)"
90
+ @error="emit('error', video?.error)"
91
+ >
92
+ <slot />
93
+ </video>
94
+ <UiButton
95
+ v-if="!controls || !playing"
96
+ color="neutral"
97
+ variant="solid"
98
+ square
99
+ :icon="playing ? 'i-lucide-pause' : 'i-lucide-play'"
100
+ :aria-label="playing ? pauseLabel : playLabel"
101
+ class="absolute top-1/2 left-1/2 size-[70px] -translate-1/2 rounded-full bg-neutral/50 text-white hover:bg-neutral/70"
102
+ @click="toggle"
103
+ />
104
+ </div>
105
+ </template>
@@ -0,0 +1,126 @@
1
+ <script setup lang="ts">
2
+ interface Service {
3
+ id: string;
4
+ label: string;
5
+ description?: string;
6
+ icon?: string;
7
+ message: string;
8
+ }
9
+ const props = defineProps<{
10
+ phone: string;
11
+ services: Service[];
12
+ helpImage?: string;
13
+ labels: {
14
+ open: string;
15
+ select: string;
16
+ preview: string;
17
+ hint: string;
18
+ web: string;
19
+ app: string;
20
+ followUp?: string;
21
+ };
22
+ }>();
23
+ const menuOpen = ref(false);
24
+ const previewOpen = ref(false);
25
+ const selected = ref<Service>();
26
+ const mobile = useMediaQuery("(max-width: 767px)");
27
+ const { dockedHeight } = useFloatingBottomStack();
28
+ const { y } = useWindowScroll();
29
+ const bottom = computed(() => dockedHeight.value + (mobile.value ? 16 : y.value > 300 ? 84 : 24));
30
+ const phone = computed(() => props.phone.replace(/[^0-9]/g, ""));
31
+ const query = computed(() =>
32
+ new URLSearchParams({ phone: phone.value, text: selected.value?.message ?? "" }).toString(),
33
+ );
34
+ const webUrl = computed(() => "https://web.whatsapp.com/send?" + query.value);
35
+ const appUrl = computed(
36
+ () =>
37
+ "https://wa.me/" +
38
+ phone.value +
39
+ "?" +
40
+ new URLSearchParams({ text: selected.value?.message ?? "" }).toString(),
41
+ );
42
+ function select(service: Service) {
43
+ selected.value = service;
44
+ menuOpen.value = false;
45
+ previewOpen.value = true;
46
+ }
47
+ </script>
48
+
49
+ <template>
50
+ <div class="fixed end-5 z-40 transition-[bottom] duration-300" :style="{ bottom: `${bottom}px` }">
51
+ <UiPopover
52
+ v-model:open="menuOpen"
53
+ :mode="mobile ? 'click' : 'hover'"
54
+ :content="{ side: 'top', align: 'end' }"
55
+ arrow
56
+ >
57
+ <template #reference>
58
+ <UiButton
59
+ :aria-label="labels.open"
60
+ color="neutral"
61
+ variant="ghost"
62
+ class="relative h-[108px] w-[110px] p-0 hover:bg-transparent max-md:size-10"
63
+ >
64
+ <UiImage
65
+ v-if="helpImage"
66
+ :src="helpImage"
67
+ alt=""
68
+ :width="130"
69
+ :height="40"
70
+ class="absolute top-0 w-full max-md:hidden"
71
+ />
72
+ <span
73
+ class="absolute end-1 bottom-1 grid size-[66px] place-items-center rounded-full bg-success/20 max-md:static max-md:size-10"
74
+ ><UiIcon name="brand:whatsapp" class="size-icon-3xl text-success max-md:size-icon-xl"
75
+ /></span>
76
+ </UiButton>
77
+ </template>
78
+ <div class="min-w-[240px] py-2">
79
+ <h3 class="border-b border-border px-4 py-3 text-sm font-semibold">{{ labels.select }}</h3>
80
+ <UiButton
81
+ v-for="service in services"
82
+ :key="service.id"
83
+ color="neutral"
84
+ variant="ghost"
85
+ class="h-auto w-full justify-start rounded-none px-4 py-3"
86
+ :icon="service.icon"
87
+ @click="select(service)"
88
+ >
89
+ <span class="text-start"
90
+ ><strong class="block text-sm">{{ service.label }}</strong
91
+ ><span v-if="service.description" class="block text-xs text-muted">{{
92
+ service.description
93
+ }}</span></span
94
+ >
95
+ </UiButton>
96
+ </div>
97
+ </UiPopover>
98
+ <UiModal
99
+ v-model:open="previewOpen"
100
+ :title="labels.preview"
101
+ :description="labels.hint"
102
+ :ui="{ content: 'w-[400px] max-w-[92vw]' }"
103
+ >
104
+ <template #body>
105
+ <pre
106
+ class="rounded-lg border border-border bg-surface-muted p-2.5 text-xs whitespace-pre-wrap"
107
+ >{{ selected?.message }}</pre>
108
+ </template>
109
+ <template #footer>
110
+ <div class="grid w-full grid-cols-2 gap-2">
111
+ <UiButton
112
+ :href="webUrl"
113
+ target="_blank"
114
+ external
115
+ color="neutral"
116
+ variant="outline"
117
+ block
118
+ >{{ labels.web }}</UiButton
119
+ >
120
+ <UiButton :href="appUrl" target="_blank" external block>{{ labels.app }}</UiButton>
121
+ </div>
122
+ <p v-if="labels.followUp" class="text-xs text-muted">{{ labels.followUp }}</p>
123
+ </template>
124
+ </UiModal>
125
+ </div>
126
+ </template>