srcdev-nuxt-components 9.2.0 → 9.2.2
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/.claude/settings.json +5 -2
- package/.claude/skills/components/banner-video.md +88 -23
- package/.claude/skills/index.md +1 -1
- package/app/components/01.atoms/banner-video/BannerVideo.vue +62 -29
- package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +120 -0
- package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +53 -2
- package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +95 -0
- package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +2 -2
- package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +143 -0
- package/app/components/02.molecules/input-copy/InputCopy.vue +195 -0
- package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +110 -0
- package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +181 -0
- package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +149 -0
- package/app/components/02.molecules/pricing-card/PricingCard.vue +222 -0
- package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +160 -0
- package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +139 -0
- package/package.json +1 -1
- package/.claude/skills/components/input-copy-core.md +0 -66
- package/app/components/05.forms/input-copy/InputCopyCore.vue +0 -132
- package/app/components/05.forms/input-copy/stories/InputCopyCore.stories.ts +0 -89
- package/app/components/05.forms/input-copy/tests/InputCopyCore.spec.ts +0 -212
- package/app/components/05.forms/input-copy/tests/__snapshots__/InputCopyCore.spec.ts.snap +0 -28
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { mount, flushPromises } from "@vue/test-utils";
|
|
3
|
+
import InputCopy from "../InputCopy.vue";
|
|
4
|
+
|
|
5
|
+
describe("InputCopy", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.clearAllMocks();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("renders readonly input with value", () => {
|
|
11
|
+
const wrapper = mount(InputCopy, {
|
|
12
|
+
props: {
|
|
13
|
+
value: "test-key-123",
|
|
14
|
+
label: "Test key",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const input = wrapper.find("input");
|
|
19
|
+
expect(input.element.value).toBe("test-key-123");
|
|
20
|
+
expect(input.element.readOnly).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("displays description when provided", () => {
|
|
24
|
+
const wrapper = mount(InputCopy, {
|
|
25
|
+
props: {
|
|
26
|
+
value: "test-key",
|
|
27
|
+
description: "This is your license key.",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(wrapper.text()).toContain("This is your license key.");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("does not display description when not provided", () => {
|
|
35
|
+
const wrapper = mount(InputCopy, {
|
|
36
|
+
props: {
|
|
37
|
+
value: "test-key",
|
|
38
|
+
description: undefined,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const description = wrapper.find(".input-copy__description");
|
|
43
|
+
expect(description.exists()).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders copy button with default text", () => {
|
|
47
|
+
const wrapper = mount(InputCopy, {
|
|
48
|
+
props: {
|
|
49
|
+
value: "test-key",
|
|
50
|
+
buttonText: "Copy",
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const button = wrapper.find("button");
|
|
55
|
+
expect(button.text()).toContain("Copy");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("renders copy button with custom text", () => {
|
|
59
|
+
const wrapper = mount(InputCopy, {
|
|
60
|
+
props: {
|
|
61
|
+
value: "test-key",
|
|
62
|
+
buttonText: "Copy to clipboard",
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const button = wrapper.find("button");
|
|
67
|
+
expect(button.text()).toContain("Copy to clipboard");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("disables button when isDisabled is true", () => {
|
|
71
|
+
const wrapper = mount(InputCopy, {
|
|
72
|
+
props: {
|
|
73
|
+
value: "test-key",
|
|
74
|
+
isDisabled: true,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const button = wrapper.find("button");
|
|
79
|
+
expect(button.attributes("disabled")).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("button is not disabled when isDisabled is false", () => {
|
|
83
|
+
const wrapper = mount(InputCopy, {
|
|
84
|
+
props: {
|
|
85
|
+
value: "test-key",
|
|
86
|
+
isDisabled: false,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const button = wrapper.find("button");
|
|
91
|
+
expect(button.attributes("disabled")).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("has proper aria-label on input from label prop", () => {
|
|
95
|
+
const wrapper = mount(InputCopy, {
|
|
96
|
+
props: {
|
|
97
|
+
value: "test-key",
|
|
98
|
+
label: "License key",
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const input = wrapper.find("input");
|
|
103
|
+
expect(input.attributes("aria-label")).toBe("License key");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("has proper aria-label on input from ariaLabel prop (takes precedence)", () => {
|
|
107
|
+
const wrapper = mount(InputCopy, {
|
|
108
|
+
props: {
|
|
109
|
+
value: "test-key",
|
|
110
|
+
label: "License key",
|
|
111
|
+
ariaLabel: "Unique license identifier",
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const input = wrapper.find("input");
|
|
116
|
+
expect(input.attributes("aria-label")).toBe("Unique license identifier");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("has proper aria-label on button", () => {
|
|
120
|
+
const wrapper = mount(InputCopy, {
|
|
121
|
+
props: {
|
|
122
|
+
value: "test-key",
|
|
123
|
+
label: "API token",
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const button = wrapper.find("button");
|
|
128
|
+
expect(button.attributes("aria-label")).toContain("Copy");
|
|
129
|
+
expect(button.attributes("aria-label")).toContain("API token");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("has icon element in button", () => {
|
|
133
|
+
const wrapper = mount(InputCopy, {
|
|
134
|
+
props: {
|
|
135
|
+
value: "test-key",
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const icon = wrapper.find(".input-copy__icon");
|
|
140
|
+
expect(icon.exists()).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("input wrapper contains both input and button", () => {
|
|
144
|
+
const wrapper = mount(InputCopy, {
|
|
145
|
+
props: {
|
|
146
|
+
value: "test-key",
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const wrapper_el = wrapper.find(".input-copy__wrapper");
|
|
151
|
+
expect(wrapper_el.exists()).toBe(true);
|
|
152
|
+
|
|
153
|
+
const input = wrapper_el.find("input");
|
|
154
|
+
const button = wrapper_el.find("button");
|
|
155
|
+
|
|
156
|
+
expect(input.exists()).toBe(true);
|
|
157
|
+
expect(button.exists()).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("renders with correct component classes", () => {
|
|
161
|
+
const wrapper = mount(InputCopy, {
|
|
162
|
+
props: {
|
|
163
|
+
value: "test-key",
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(wrapper.classes()).toContain("input-copy");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("applies styleClassPassthrough classes", () => {
|
|
171
|
+
const wrapper = mount(InputCopy, {
|
|
172
|
+
props: {
|
|
173
|
+
value: "test-key",
|
|
174
|
+
styleClassPassthrough: ["custom-class", "another-class"],
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(wrapper.classes()).toContain("custom-class");
|
|
179
|
+
expect(wrapper.classes()).toContain("another-class");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# PricingCard — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
All `--pricing-card-*` tokens are the stable override surface. Set them at any scope (global, page,
|
|
6
|
+
or instance) without touching the component itself.
|
|
7
|
+
|
|
8
|
+
| Token | Default | Controls |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| `--pricing-card-border` | `1px solid var(--slate-03)` | Card border |
|
|
11
|
+
| `--pricing-card-border-radius` | `0.8rem` | Corner rounding |
|
|
12
|
+
| `--pricing-card-padding` | `2rem` | Internal padding |
|
|
13
|
+
| `--pricing-card-background` | `var(--slate-00)` | Card background colour |
|
|
14
|
+
| `--pricing-card-shadow` | `0 2px 8px oklch(...)` | Box shadow |
|
|
15
|
+
| `--pricing-card-gap` | `1.2rem` | Gap between elements (name, price, features, CTA) |
|
|
16
|
+
| `--pricing-card-highlight-border` | `2px solid var(--teal-06)` | Border when `isHighlighted` |
|
|
17
|
+
| `--pricing-card-highlight-shadow` | `0 8px 24px oklch(...)` | Shadow when highlighted |
|
|
18
|
+
| `--pricing-card-highlight-scale` | `1.05` | Scale transform when highlighted |
|
|
19
|
+
| `--pricing-card-badge-bg` | `var(--teal-06)` | "Most Popular" badge background |
|
|
20
|
+
| `--pricing-card-badge-text` | `var(--teal-00)` | Badge text colour |
|
|
21
|
+
| `--pricing-card-name-font-size` | `1.8rem` | Plan name heading size |
|
|
22
|
+
| `--pricing-card-name-color` | `#1a1a1a` | Plan name text colour |
|
|
23
|
+
| `--pricing-card-amount-font-size` | `3.2rem` | Price amount size |
|
|
24
|
+
| `--pricing-card-amount-color` | `#1a1a1a` | Price text colour |
|
|
25
|
+
| `--pricing-card-period-font-size` | `0.9rem` | Billing period ("one-time", "per month") size |
|
|
26
|
+
| `--pricing-card-period-color` | `#666` | Billing period text colour |
|
|
27
|
+
| `--pricing-card-description-color` | `#555` | Plan description text colour |
|
|
28
|
+
| `--pricing-card-feature-color` | `#333` | Feature list text colour |
|
|
29
|
+
| `--pricing-card-cta-bg` | `var(--theme-button-primary-surface)` | CTA button background |
|
|
30
|
+
| `--pricing-card-cta-bg-hover` | `var(--theme-button-primary-surface-hover)` | CTA button background on hover |
|
|
31
|
+
| `--pricing-card-cta-text` | `var(--theme-button-primary-text)` | CTA button text colour |
|
|
32
|
+
| `--pricing-card-cta-padding` | `1rem 1.6rem` | CTA button padding |
|
|
33
|
+
| `--pricing-card-cta-border-radius` | `0.4rem` | CTA button corner rounding |
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Global theming — app-level CSS file
|
|
38
|
+
|
|
39
|
+
Create `assets/styles/setup/07.components/pricing-card.css` in the consuming app and set tokens on
|
|
40
|
+
`:root`. These values apply to every `PricingCard` instance across the site.
|
|
41
|
+
|
|
42
|
+
```css
|
|
43
|
+
/* assets/styles/setup/07.components/pricing-card.css */
|
|
44
|
+
:root {
|
|
45
|
+
--pricing-card-border-radius: 1rem;
|
|
46
|
+
--pricing-card-padding: 2.4rem;
|
|
47
|
+
--pricing-card-shadow: 0 4px 12px rgb(0 0 0 / 10%);
|
|
48
|
+
--pricing-card-gap: 1.6rem;
|
|
49
|
+
--pricing-card-badge-bg: var(--brand-primary);
|
|
50
|
+
--pricing-card-highlight-border: 2px solid var(--brand-primary);
|
|
51
|
+
--pricing-card-highlight-shadow: 0 12px 32px rgb(0 0 0 / 15%);
|
|
52
|
+
--pricing-card-cta-border-radius: 0.6rem;
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Per-instance overrides via inline styles
|
|
59
|
+
|
|
60
|
+
Override tokens directly on a single card instance:
|
|
61
|
+
|
|
62
|
+
```vue
|
|
63
|
+
<PricingCard
|
|
64
|
+
planName="Enterprise"
|
|
65
|
+
:price="999"
|
|
66
|
+
style="
|
|
67
|
+
--pricing-card-border-radius: 1.2rem;
|
|
68
|
+
--pricing-card-highlight-shadow: 0 16px 48px rgb(0 0 0 / 20%);
|
|
69
|
+
"
|
|
70
|
+
@select="handleSelect"
|
|
71
|
+
/>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Page-scoped overrides
|
|
77
|
+
|
|
78
|
+
When pricing cards appear in a specific page context, scope overrides under the page wrapper:
|
|
79
|
+
|
|
80
|
+
```css
|
|
81
|
+
/* In the consuming page's unscoped <style> block */
|
|
82
|
+
.pricing-page {
|
|
83
|
+
.pricing-card {
|
|
84
|
+
--pricing-card-border-radius: 1.2rem;
|
|
85
|
+
--pricing-card-padding: 2.8rem;
|
|
86
|
+
--pricing-card-gap: 1.8rem;
|
|
87
|
+
|
|
88
|
+
&.is-highlighted {
|
|
89
|
+
--pricing-card-highlight-scale: 1.08;
|
|
90
|
+
--pricing-card-highlight-shadow: 0 20px 48px rgb(0 0 0 / 18%);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Three-card comparison grid
|
|
99
|
+
|
|
100
|
+
When displaying multiple cards side-by-side (common in pricing pages), wrap in a grid container:
|
|
101
|
+
|
|
102
|
+
```vue
|
|
103
|
+
<div class="pricing-grid">
|
|
104
|
+
<PricingCard v-for="plan in plans" :key="plan.id" v-bind="plan" @select="handleSelect" />
|
|
105
|
+
</div>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```css
|
|
109
|
+
.pricing-grid {
|
|
110
|
+
display: grid;
|
|
111
|
+
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
112
|
+
gap: 2rem;
|
|
113
|
+
|
|
114
|
+
.pricing-card {
|
|
115
|
+
/* Optional per-card overrides */
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* Alternate: fixed 3-column layout */
|
|
119
|
+
&.three-col {
|
|
120
|
+
grid-template-columns: repeat(3, 1fr);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Feature checkmark colour
|
|
128
|
+
|
|
129
|
+
The feature list checkmarks use `--teal-06` by default. Override globally or per-instance:
|
|
130
|
+
|
|
131
|
+
```css
|
|
132
|
+
.pricing-page {
|
|
133
|
+
.pricing-card__feature::before {
|
|
134
|
+
color: var(--brand-primary);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Or inline:
|
|
140
|
+
|
|
141
|
+
```vue
|
|
142
|
+
<PricingCard
|
|
143
|
+
v-bind="plan"
|
|
144
|
+
style="--_feature-checkmark-color: var(--brand-accent);"
|
|
145
|
+
@select="handleSelect"
|
|
146
|
+
/>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
(Note: `--_feature-checkmark-color` is an internal token; consider requesting it as a public token if override is needed frequently.)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component :is="tag" class="pricing-card" :class="[elementClasses, { 'is-highlighted': isHighlighted }]">
|
|
3
|
+
<div v-if="isHighlighted" class="pricing-card__badge">Most Popular</div>
|
|
4
|
+
|
|
5
|
+
<h3 class="pricing-card__name">{{ planName }}</h3>
|
|
6
|
+
|
|
7
|
+
<div class="pricing-card__price">
|
|
8
|
+
<span class="pricing-card__amount">${{ price }}</span>
|
|
9
|
+
<span v-if="billingPeriod" class="pricing-card__period">{{ billingPeriod }}</span>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<p v-if="description" class="pricing-card__description">{{ description }}</p>
|
|
13
|
+
|
|
14
|
+
<ul class="pricing-card__features">
|
|
15
|
+
<slot name="features">
|
|
16
|
+
<li v-for="(feature, index) in features" :key="index" class="pricing-card__feature">
|
|
17
|
+
{{ feature }}
|
|
18
|
+
</li>
|
|
19
|
+
</slot>
|
|
20
|
+
</ul>
|
|
21
|
+
|
|
22
|
+
<button
|
|
23
|
+
class="pricing-card__cta"
|
|
24
|
+
:disabled="ctaDisabled"
|
|
25
|
+
@click="handleSelect"
|
|
26
|
+
>
|
|
27
|
+
{{ ctaText }}
|
|
28
|
+
</button>
|
|
29
|
+
</component>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<script setup lang="ts">
|
|
33
|
+
interface Props {
|
|
34
|
+
tag?: "div" | "section" | "article";
|
|
35
|
+
planName: string;
|
|
36
|
+
price: number;
|
|
37
|
+
billingPeriod?: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
features?: string[];
|
|
40
|
+
isHighlighted?: boolean;
|
|
41
|
+
ctaText?: string;
|
|
42
|
+
ctaDisabled?: boolean;
|
|
43
|
+
styleClassPassthrough?: string | string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
47
|
+
tag: "article",
|
|
48
|
+
billingPeriod: "one-time",
|
|
49
|
+
description: undefined,
|
|
50
|
+
features: () => [],
|
|
51
|
+
isHighlighted: false,
|
|
52
|
+
ctaText: "Get started",
|
|
53
|
+
ctaDisabled: false,
|
|
54
|
+
styleClassPassthrough: () => [],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const emit = defineEmits<{
|
|
58
|
+
select: [planName: string];
|
|
59
|
+
}>();
|
|
60
|
+
|
|
61
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
62
|
+
|
|
63
|
+
const handleSelect = () => {
|
|
64
|
+
emit("select", props.planName);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
watch(
|
|
68
|
+
() => props.styleClassPassthrough,
|
|
69
|
+
() => {
|
|
70
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<style lang="css">
|
|
76
|
+
@layer components {
|
|
77
|
+
.pricing-card {
|
|
78
|
+
--_card-border: var(--pricing-card-border, 1px solid var(--slate-03));
|
|
79
|
+
--_card-border-radius: var(--pricing-card-border-radius, 0.8rem);
|
|
80
|
+
--_card-padding: var(--pricing-card-padding, 2rem);
|
|
81
|
+
--_card-background: var(--pricing-card-background, var(--slate-00));
|
|
82
|
+
--_card-shadow: var(--pricing-card-shadow, 0 2px 8px oklch(from var(--slate-08) l c h / 0.12));
|
|
83
|
+
--_card-gap: var(--pricing-card-gap, 1.2rem);
|
|
84
|
+
|
|
85
|
+
--_highlight-border: var(--pricing-card-highlight-border, 2px solid var(--teal-06));
|
|
86
|
+
--_highlight-shadow: var(--pricing-card-highlight-shadow, 0 8px 24px oklch(from var(--teal-06) l c h / 0.20));
|
|
87
|
+
--_highlight-scale: var(--pricing-card-highlight-scale, 1.05);
|
|
88
|
+
|
|
89
|
+
--_badge-bg: var(--pricing-card-badge-bg, var(--teal-06));
|
|
90
|
+
--_badge-text: var(--pricing-card-badge-text, var(--teal-00));
|
|
91
|
+
|
|
92
|
+
--_name-font-size: var(--pricing-card-name-font-size, 1.8rem);
|
|
93
|
+
--_name-color: var(--pricing-card-name-color, #1a1a1a);
|
|
94
|
+
|
|
95
|
+
--_amount-font-size: var(--pricing-card-amount-font-size, 3.2rem);
|
|
96
|
+
--_amount-color: var(--pricing-card-amount-color, #1a1a1a);
|
|
97
|
+
--_period-font-size: var(--pricing-card-period-font-size, 0.9rem);
|
|
98
|
+
--_period-color: var(--pricing-card-period-color, #666);
|
|
99
|
+
|
|
100
|
+
--_description-color: var(--pricing-card-description-color, #555);
|
|
101
|
+
--_feature-color: var(--pricing-card-feature-color, #333);
|
|
102
|
+
|
|
103
|
+
--_cta-bg: var(--pricing-card-cta-bg, var(--theme-button-primary-surface));
|
|
104
|
+
--_cta-bg-hover: var(--pricing-card-cta-bg-hover, var(--theme-button-primary-surface-hover));
|
|
105
|
+
--_cta-text: var(--pricing-card-cta-text, var(--theme-button-primary-text));
|
|
106
|
+
--_cta-padding: var(--pricing-card-cta-padding, 1rem 1.6rem);
|
|
107
|
+
--_cta-border-radius: var(--pricing-card-cta-border-radius, 0.4rem);
|
|
108
|
+
|
|
109
|
+
display: flex;
|
|
110
|
+
flex-direction: column;
|
|
111
|
+
gap: var(--_card-gap);
|
|
112
|
+
position: relative;
|
|
113
|
+
|
|
114
|
+
padding: var(--_card-padding);
|
|
115
|
+
background-color: var(--_card-background);
|
|
116
|
+
border: var(--_card-border);
|
|
117
|
+
border-radius: var(--_card-border-radius);
|
|
118
|
+
box-shadow: var(--_card-shadow);
|
|
119
|
+
|
|
120
|
+
transition: all 0.3s ease;
|
|
121
|
+
|
|
122
|
+
&.is-highlighted {
|
|
123
|
+
border: var(--_highlight-border);
|
|
124
|
+
box-shadow: var(--_highlight-shadow);
|
|
125
|
+
transform: scale(var(--_highlight-scale));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.pricing-card__badge {
|
|
129
|
+
position: absolute;
|
|
130
|
+
top: -0.6rem;
|
|
131
|
+
left: 50%;
|
|
132
|
+
transform: translateX(-50%);
|
|
133
|
+
|
|
134
|
+
display: inline-block;
|
|
135
|
+
padding: 0.4rem 1rem;
|
|
136
|
+
background-color: var(--_badge-bg);
|
|
137
|
+
color: var(--_badge-text);
|
|
138
|
+
border-radius: 2rem;
|
|
139
|
+
font-size: 0.75rem;
|
|
140
|
+
font-weight: 600;
|
|
141
|
+
text-transform: uppercase;
|
|
142
|
+
letter-spacing: 0.05em;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.pricing-card__name {
|
|
146
|
+
margin: 0;
|
|
147
|
+
font-size: var(--_name-font-size);
|
|
148
|
+
font-weight: 600;
|
|
149
|
+
color: var(--_name-color);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.pricing-card__price {
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: baseline;
|
|
155
|
+
gap: 0.4rem;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.pricing-card__amount {
|
|
159
|
+
font-size: var(--_amount-font-size);
|
|
160
|
+
font-weight: 700;
|
|
161
|
+
color: var(--_amount-color);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.pricing-card__period {
|
|
165
|
+
font-size: var(--_period-font-size);
|
|
166
|
+
color: var(--_period-color);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.pricing-card__description {
|
|
170
|
+
margin: 0;
|
|
171
|
+
color: var(--_description-color);
|
|
172
|
+
line-height: 1.5;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.pricing-card__features {
|
|
176
|
+
list-style: none;
|
|
177
|
+
padding: 0;
|
|
178
|
+
margin: 0;
|
|
179
|
+
display: flex;
|
|
180
|
+
flex-direction: column;
|
|
181
|
+
gap: 0.6rem;
|
|
182
|
+
|
|
183
|
+
flex-grow: 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.pricing-card__feature {
|
|
187
|
+
color: var(--_feature-color);
|
|
188
|
+
padding-left: 1.6rem;
|
|
189
|
+
position: relative;
|
|
190
|
+
|
|
191
|
+
&::before {
|
|
192
|
+
content: "✓";
|
|
193
|
+
position: absolute;
|
|
194
|
+
left: 0;
|
|
195
|
+
color: var(--teal-06);
|
|
196
|
+
font-weight: bold;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.pricing-card__cta {
|
|
201
|
+
align-self: flex-start;
|
|
202
|
+
padding: var(--_cta-padding);
|
|
203
|
+
background-color: var(--_cta-bg);
|
|
204
|
+
color: var(--_cta-text);
|
|
205
|
+
border: none;
|
|
206
|
+
border-radius: var(--_cta-border-radius);
|
|
207
|
+
font-weight: 600;
|
|
208
|
+
cursor: pointer;
|
|
209
|
+
transition: background-color 0.2s ease;
|
|
210
|
+
|
|
211
|
+
&:hover:not(:disabled) {
|
|
212
|
+
background-color: var(--_cta-bg-hover);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
&:disabled {
|
|
216
|
+
opacity: 0.5;
|
|
217
|
+
cursor: not-allowed;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
</style>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
2
|
+
import PricingCard from "../PricingCard.vue";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof PricingCard> = {
|
|
5
|
+
title: "Molecules/Pricing Card",
|
|
6
|
+
component: PricingCard,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
argTypes: {
|
|
9
|
+
planName: { control: "text" },
|
|
10
|
+
price: { control: "number" },
|
|
11
|
+
billingPeriod: { control: "text" },
|
|
12
|
+
description: { control: "text" },
|
|
13
|
+
isHighlighted: { control: "boolean" },
|
|
14
|
+
ctaText: { control: "text" },
|
|
15
|
+
ctaDisabled: { control: "boolean" },
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
planName: "Single Site",
|
|
19
|
+
price: 99,
|
|
20
|
+
billingPeriod: "one-time",
|
|
21
|
+
description: "Perfect for freelancers and solo practitioners.",
|
|
22
|
+
features: ["Embed on one domain", "Unlimited consultations", "Email support", "Monthly updates"],
|
|
23
|
+
isHighlighted: false,
|
|
24
|
+
ctaText: "Get started",
|
|
25
|
+
ctaDisabled: false,
|
|
26
|
+
},
|
|
27
|
+
parameters: {
|
|
28
|
+
docs: {
|
|
29
|
+
description: {
|
|
30
|
+
component:
|
|
31
|
+
"A SaaS pricing plan card displaying plan name, price, features, and a CTA button. " +
|
|
32
|
+
"Supports a 'Most Popular' highlight state with scaled/emphasized styling. " +
|
|
33
|
+
"Emits a `select` event when the CTA button is clicked. " +
|
|
34
|
+
"Use multiple cards in a grid layout to build a pricing comparison table.",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default meta;
|
|
41
|
+
type Story = StoryObj<typeof meta>;
|
|
42
|
+
|
|
43
|
+
const storyWrapper = (args: Record<string, unknown>, handleSelect: (planName: string) => void) => ({
|
|
44
|
+
components: { PricingCard },
|
|
45
|
+
setup() {
|
|
46
|
+
return { args, handleSelect };
|
|
47
|
+
},
|
|
48
|
+
template: `
|
|
49
|
+
<div style="padding: 2rem; background: #f5f5f5; border-radius: 0.5rem;">
|
|
50
|
+
<PricingCard v-bind="args" @select="handleSelect" />
|
|
51
|
+
</div>
|
|
52
|
+
`,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const Default: Story = {
|
|
56
|
+
render: (args) => {
|
|
57
|
+
const handleSelect = (planName: string) => {
|
|
58
|
+
console.log(`Selected plan: ${planName}`);
|
|
59
|
+
};
|
|
60
|
+
return storyWrapper(args, handleSelect);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const Highlighted: Story = {
|
|
65
|
+
args: {
|
|
66
|
+
planName: "Agency",
|
|
67
|
+
price: 299,
|
|
68
|
+
description: "Best for growing teams managing multiple sites.",
|
|
69
|
+
features: ["Embed on up to 3 domains", "Unlimited consultations", "Priority email support", "Weekly updates"],
|
|
70
|
+
isHighlighted: true,
|
|
71
|
+
},
|
|
72
|
+
render: (args) => {
|
|
73
|
+
const handleSelect = (planName: string) => {
|
|
74
|
+
console.log(`Selected plan: ${planName}`);
|
|
75
|
+
};
|
|
76
|
+
return storyWrapper(args, handleSelect);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const Unlimited: Story = {
|
|
81
|
+
args: {
|
|
82
|
+
planName: "Unlimited",
|
|
83
|
+
price: 799,
|
|
84
|
+
description: "Enterprise plan for unlimited growth.",
|
|
85
|
+
features: [
|
|
86
|
+
"Unlimited domains",
|
|
87
|
+
"Unlimited consultations",
|
|
88
|
+
"24/7 priority support",
|
|
89
|
+
"Real-time updates",
|
|
90
|
+
"Custom integrations",
|
|
91
|
+
],
|
|
92
|
+
isHighlighted: false,
|
|
93
|
+
},
|
|
94
|
+
render: (args) => {
|
|
95
|
+
const handleSelect = (planName: string) => {
|
|
96
|
+
console.log(`Selected plan: ${planName}`);
|
|
97
|
+
};
|
|
98
|
+
return storyWrapper(args, handleSelect);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const Disabled: Story = {
|
|
103
|
+
args: {
|
|
104
|
+
planName: "Coming Soon",
|
|
105
|
+
price: 199,
|
|
106
|
+
description: "New plan launching soon.",
|
|
107
|
+
features: ["Feature 1", "Feature 2", "Feature 3"],
|
|
108
|
+
ctaDisabled: true,
|
|
109
|
+
ctaText: "Coming soon",
|
|
110
|
+
},
|
|
111
|
+
render: (args) => {
|
|
112
|
+
const handleSelect = (planName: string) => {
|
|
113
|
+
console.log(`Selected plan: ${planName}`);
|
|
114
|
+
};
|
|
115
|
+
return storyWrapper(args, handleSelect);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const ThreeCardComparison: Story = {
|
|
120
|
+
render: () => ({
|
|
121
|
+
components: { PricingCard },
|
|
122
|
+
setup() {
|
|
123
|
+
const handleSelect = (planName: string) => {
|
|
124
|
+
console.log(`Selected plan: ${planName}`);
|
|
125
|
+
};
|
|
126
|
+
return { handleSelect };
|
|
127
|
+
},
|
|
128
|
+
template: `
|
|
129
|
+
<div style="padding: 2rem; background: #f5f5f5; border-radius: 0.5rem;">
|
|
130
|
+
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 2rem;">
|
|
131
|
+
<PricingCard
|
|
132
|
+
planName="Single Site"
|
|
133
|
+
:price="99"
|
|
134
|
+
billingPeriod="one-time"
|
|
135
|
+
description="Perfect for freelancers and solo practitioners."
|
|
136
|
+
:features="['Embed on one domain', 'Unlimited consultations', 'Email support', 'Monthly updates']"
|
|
137
|
+
@select="handleSelect"
|
|
138
|
+
/>
|
|
139
|
+
<PricingCard
|
|
140
|
+
planName="Agency"
|
|
141
|
+
:price="299"
|
|
142
|
+
billingPeriod="one-time"
|
|
143
|
+
description="Best for growing teams managing multiple sites."
|
|
144
|
+
:features="['Embed on up to 3 domains', 'Unlimited consultations', 'Priority email support', 'Weekly updates']"
|
|
145
|
+
:isHighlighted="true"
|
|
146
|
+
@select="handleSelect"
|
|
147
|
+
/>
|
|
148
|
+
<PricingCard
|
|
149
|
+
planName="Unlimited"
|
|
150
|
+
:price="799"
|
|
151
|
+
billingPeriod="one-time"
|
|
152
|
+
description="Enterprise plan for unlimited growth."
|
|
153
|
+
:features="['Unlimited domains', 'Unlimited consultations', '24/7 priority support', 'Real-time updates', 'Custom integrations']"
|
|
154
|
+
@select="handleSelect"
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
`,
|
|
159
|
+
}),
|
|
160
|
+
};
|