@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.
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +112 -56
- package/dist/resources/components/nuxt/site-source/app/assets/icons/brand/android.svg +10 -0
- package/dist/resources/components/nuxt/site-source/app/assets/icons/brand/apple.svg +3 -0
- package/dist/resources/components/nuxt/site-source/app/assets/icons/brand/whatsapp.svg +5 -0
- package/dist/resources/components/nuxt/site-source/app/components/app/Sidebar.vue +1 -1
- package/dist/resources/components/nuxt/site-source/app/components/checkout/Status.vue +3 -3
- package/dist/resources/components/nuxt/site-source/app/components/consent/Banner.vue +23 -8
- package/dist/resources/components/nuxt/site-source/app/components/content/CallToAction.vue +59 -0
- package/dist/resources/components/nuxt/site-source/app/components/content/Card.vue +35 -0
- package/dist/resources/components/nuxt/site-source/app/components/content/Compatibility.vue +10 -0
- package/dist/resources/components/nuxt/site-source/app/components/content/Faq.vue +56 -0
- package/dist/resources/components/nuxt/site-source/app/components/content/Features.vue +8 -0
- package/dist/resources/components/nuxt/site-source/app/components/content/List.vue +1 -8
- package/dist/resources/components/nuxt/site-source/app/components/content/Reviews.vue +93 -0
- package/dist/resources/components/nuxt/site-source/app/components/countdown/Timer.vue +112 -0
- package/dist/resources/components/nuxt/site-source/app/components/locale/Menu.vue +3 -3
- package/dist/resources/components/nuxt/site-source/app/components/site/BackToTop.vue +30 -23
- package/dist/resources/components/nuxt/site-source/app/components/site/Header.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/steps/Progress.vue +36 -0
- package/dist/resources/components/nuxt/site-source/app/components/steps/Showcase.vue +340 -0
- package/dist/resources/components/nuxt/site-source/app/components/theme/Menu.vue +4 -4
- package/dist/resources/components/nuxt/site-source/app/components/ui/Button.vue +28 -2
- package/dist/resources/components/nuxt/site-source/app/components/ui/GradientBorder.vue +5 -5
- package/dist/resources/components/nuxt/site-source/app/components/ui/Icon.vue +14 -0
- package/dist/resources/components/nuxt/site-source/app/components/ui/Image.vue +126 -0
- package/dist/resources/components/nuxt/site-source/app/components/ui/Marquee.vue +99 -0
- package/dist/resources/components/nuxt/site-source/app/components/ui/Modal.vue +26 -88
- package/dist/resources/components/nuxt/site-source/app/components/ui/Popover.vue +87 -93
- package/dist/resources/components/nuxt/site-source/app/components/ui/Text.vue +84 -12
- package/dist/resources/components/nuxt/site-source/app/components/ui/Video.vue +105 -0
- package/dist/resources/components/nuxt/site-source/app/components/whatsapp/Chat.vue +126 -0
- package/dist/resources/components/nuxt/site-source/app/components/youtube/Embed.vue +286 -73
- package/dist/resources/components/nuxt/site-source/app/composables/useFloatingBottomStack.ts +44 -0
- package/dist/resources/components/nuxt/site-source/app/composables/useNavigation.ts +6 -8
- package/dist/resources/components/nuxt/site-source/app/consent/types.ts +1 -4
- package/dist/resources/components/nuxt/site-source/app/content/types.ts +3 -0
- package/dist/resources/components/nuxt/site-source/app/theme/accordion.ts +5 -4
- package/dist/resources/components/nuxt/site-source/app/theme/breadcrumb.ts +14 -4
- package/dist/resources/components/nuxt/site-source/app/theme/button.ts +138 -28
- package/dist/resources/components/nuxt/site-source/app/theme/carousel.ts +11 -7
- package/dist/resources/components/nuxt/site-source/app/theme/checkbox.ts +9 -3
- package/dist/resources/components/nuxt/site-source/app/theme/drawer.ts +6 -0
- package/dist/resources/components/nuxt/site-source/app/theme/form-field.ts +8 -0
- package/dist/resources/components/nuxt/site-source/app/theme/icon.ts +6 -0
- package/dist/resources/components/nuxt/site-source/app/theme/index.ts +8 -1
- package/dist/resources/components/nuxt/site-source/app/theme/input.ts +34 -18
- package/dist/resources/components/nuxt/site-source/app/theme/modal.ts +9 -5
- package/dist/resources/components/nuxt/site-source/app/theme/pagination.ts +9 -2
- package/dist/resources/components/nuxt/site-source/app/theme/radio-group.ts +7 -0
- package/dist/resources/components/nuxt/site-source/app/theme/select.ts +19 -7
- package/dist/resources/components/nuxt/site-source/app/theme/stepper.ts +15 -6
- package/dist/resources/components/nuxt/site-source/app/theme/textarea.ts +6 -0
- package/dist/resources/components/nuxt/site-source/app/theme/toast.ts +1 -4
- package/dist/resources/components/nuxt/site-source/app/theme/tooltip.ts +5 -1
- package/dist/resources/components/registry.json +133 -45
- package/dist/resources/components/vue/ui/BottomSheet.vue +5 -1
- package/dist/resources/components/vue/ui/ScrollArea.vue +2 -1
- package/dist/resources/registry.json +1 -1
- package/dist/resources/templates/base/.claude/docs/architecture-template.md +2 -1
- package/dist/resources/templates/base/AGENTS.md +7 -0
- package/dist/resources/templates/base/CLAUDE.md +7 -0
- package/dist/resources/templates/nuxt/.claude/docs/stack.md +14 -1
- package/dist/resources/templates/nuxt/DESIGN.md +146 -0
- package/dist/resources/templates/nuxt/app/app.config.ts +2 -0
- package/dist/resources/templates/nuxt/app/pages/app/index.vue +9 -7
- package/dist/resources/templates/nuxt/app/pages/index.vue +12 -27
- package/dist/resources/templates/nuxt/app/pages/ui/index.vue +219 -3
- package/dist/resources/templates/nuxt/nuxt.config.ts +2 -4
- package/dist/resources/templates/nuxt/package.json +2 -1
- package/dist/resources/templates/nuxt/stylelint.config.mjs +12 -0
- package/package.json +3 -3
- 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?:
|
|
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
|
-
<
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
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="
|
|
60
|
-
<header v-if="title || description" class="
|
|
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="
|
|
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="
|
|
68
|
-
<
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
<
|
|
47
|
-
<
|
|
48
|
-
|
|
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:
|
|
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:
|
|
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>
|