srcdev-nuxt-components 9.2.1 → 9.2.3
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/.vscode/extensions.json +15 -0
- package/.vscode/settings.json +45 -0
- package/.vscode/srcdev-component-banner-video.code-snippets +59 -0
- package/.vscode/srcdev-component-dialog.code-snippets +126 -0
- package/.vscode/srcdev-component-display-prompt.code-snippets +66 -0
- package/.vscode/srcdev-component-display-toast.code-snippets +78 -0
- package/.vscode/srcdev-component-input-copy.code-snippets +72 -0
- package/.vscode/srcdev-component-pinia-store.code-snippets +63 -0
- package/.vscode/srcdev-component-pricing-card.code-snippets +96 -0
- package/.vscode/srcdev-component-services-section.code-snippets +84 -0
- 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 +225 -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/nuxt.config.ts +6 -1
- package/package.json +2 -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,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,225 @@
|
|
|
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
|
+
<div class="pricing-card__cta">
|
|
23
|
+
<slot
|
|
24
|
+
name="cta"
|
|
25
|
+
:cta-text="ctaText"
|
|
26
|
+
:is-disabled="ctaDisabled"
|
|
27
|
+
:plan-name="planName"
|
|
28
|
+
:on-select="handleSelect"
|
|
29
|
+
>
|
|
30
|
+
<InputButtonCore
|
|
31
|
+
:button-text="ctaText"
|
|
32
|
+
:readonly="ctaDisabled"
|
|
33
|
+
@click="handleSelect"
|
|
34
|
+
/>
|
|
35
|
+
</slot>
|
|
36
|
+
</div>
|
|
37
|
+
</component>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<script setup lang="ts">
|
|
41
|
+
interface Props {
|
|
42
|
+
tag?: "div" | "section" | "article";
|
|
43
|
+
planName: string;
|
|
44
|
+
price: number;
|
|
45
|
+
billingPeriod?: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
features?: string[];
|
|
48
|
+
isHighlighted?: boolean;
|
|
49
|
+
ctaText?: string;
|
|
50
|
+
ctaDisabled?: boolean;
|
|
51
|
+
styleClassPassthrough?: string | string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
55
|
+
tag: "article",
|
|
56
|
+
billingPeriod: "one-time",
|
|
57
|
+
description: undefined,
|
|
58
|
+
features: () => [],
|
|
59
|
+
isHighlighted: false,
|
|
60
|
+
ctaText: "Get started",
|
|
61
|
+
ctaDisabled: false,
|
|
62
|
+
styleClassPassthrough: () => [],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const emit = defineEmits<{
|
|
66
|
+
select: [planName: string];
|
|
67
|
+
}>();
|
|
68
|
+
|
|
69
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
70
|
+
|
|
71
|
+
const handleSelect = () => {
|
|
72
|
+
emit("select", props.planName);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
watch(
|
|
76
|
+
() => props.styleClassPassthrough,
|
|
77
|
+
() => {
|
|
78
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<style lang="css">
|
|
84
|
+
@layer components {
|
|
85
|
+
.pricing-card {
|
|
86
|
+
--_card-border: var(--pricing-card-border, 1px solid var(--slate-03));
|
|
87
|
+
--_card-border-radius: var(--pricing-card-border-radius, 0.8rem);
|
|
88
|
+
--_card-padding: var(--pricing-card-padding, 2rem);
|
|
89
|
+
--_card-background: var(--pricing-card-background, var(--slate-00));
|
|
90
|
+
--_card-shadow: var(--pricing-card-shadow, 0 2px 8px oklch(from var(--slate-08) l c h / 0.12));
|
|
91
|
+
--_card-gap: var(--pricing-card-gap, 1.2rem);
|
|
92
|
+
|
|
93
|
+
--_highlight-border: var(--pricing-card-highlight-border, 2px solid var(--teal-06));
|
|
94
|
+
--_highlight-shadow: var(--pricing-card-highlight-shadow, 0 8px 24px oklch(from var(--teal-06) l c h / 0.20));
|
|
95
|
+
--_highlight-scale: var(--pricing-card-highlight-scale, 1.05);
|
|
96
|
+
|
|
97
|
+
--_badge-bg: var(--pricing-card-badge-bg, var(--teal-06));
|
|
98
|
+
--_badge-text: var(--pricing-card-badge-text, var(--teal-00));
|
|
99
|
+
|
|
100
|
+
--_name-font-size: var(--pricing-card-name-font-size, 1.8rem);
|
|
101
|
+
--_name-color: var(--pricing-card-name-color, #1a1a1a);
|
|
102
|
+
|
|
103
|
+
--_amount-font-size: var(--pricing-card-amount-font-size, 3.2rem);
|
|
104
|
+
--_amount-color: var(--pricing-card-amount-color, #1a1a1a);
|
|
105
|
+
--_period-font-size: var(--pricing-card-period-font-size, 0.9rem);
|
|
106
|
+
--_period-color: var(--pricing-card-period-color, #666);
|
|
107
|
+
|
|
108
|
+
--_description-color: var(--pricing-card-description-color, #555);
|
|
109
|
+
--_feature-color: var(--pricing-card-feature-color, #333);
|
|
110
|
+
|
|
111
|
+
--_cta-bg: var(--pricing-card-cta-bg, var(--theme-button-primary-surface));
|
|
112
|
+
--_cta-bg-hover: var(--pricing-card-cta-bg-hover, var(--theme-button-primary-surface-hover));
|
|
113
|
+
--_cta-text: var(--pricing-card-cta-text, var(--theme-button-primary-text));
|
|
114
|
+
--_cta-padding: var(--pricing-card-cta-padding, 1rem 1.6rem);
|
|
115
|
+
--_cta-border-radius: var(--pricing-card-cta-border-radius, 0.4rem);
|
|
116
|
+
|
|
117
|
+
display: flex;
|
|
118
|
+
flex-direction: column;
|
|
119
|
+
gap: var(--_card-gap);
|
|
120
|
+
position: relative;
|
|
121
|
+
|
|
122
|
+
padding: var(--_card-padding);
|
|
123
|
+
background-color: var(--_card-background);
|
|
124
|
+
border: var(--_card-border);
|
|
125
|
+
border-radius: var(--_card-border-radius);
|
|
126
|
+
box-shadow: var(--_card-shadow);
|
|
127
|
+
|
|
128
|
+
transition: all 0.3s ease;
|
|
129
|
+
|
|
130
|
+
&.is-highlighted {
|
|
131
|
+
border: var(--_highlight-border);
|
|
132
|
+
box-shadow: var(--_highlight-shadow);
|
|
133
|
+
transform: scale(var(--_highlight-scale));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.pricing-card__badge {
|
|
137
|
+
position: absolute;
|
|
138
|
+
top: -0.6rem;
|
|
139
|
+
left: 50%;
|
|
140
|
+
transform: translateX(-50%);
|
|
141
|
+
|
|
142
|
+
display: inline-block;
|
|
143
|
+
padding: 0.4rem 1rem;
|
|
144
|
+
background-color: var(--_badge-bg);
|
|
145
|
+
color: var(--_badge-text);
|
|
146
|
+
border-radius: 2rem;
|
|
147
|
+
font-size: 0.75rem;
|
|
148
|
+
font-weight: 600;
|
|
149
|
+
text-transform: uppercase;
|
|
150
|
+
letter-spacing: 0.05em;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.pricing-card__name {
|
|
154
|
+
margin: 0;
|
|
155
|
+
font-size: var(--_name-font-size);
|
|
156
|
+
font-weight: 600;
|
|
157
|
+
color: var(--_name-color);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.pricing-card__price {
|
|
161
|
+
display: flex;
|
|
162
|
+
align-items: baseline;
|
|
163
|
+
gap: 0.4rem;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.pricing-card__amount {
|
|
167
|
+
font-size: var(--_amount-font-size);
|
|
168
|
+
font-weight: 700;
|
|
169
|
+
color: var(--_amount-color);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.pricing-card__period {
|
|
173
|
+
font-size: var(--_period-font-size);
|
|
174
|
+
color: var(--_period-color);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.pricing-card__description {
|
|
178
|
+
margin: 0;
|
|
179
|
+
color: var(--_description-color);
|
|
180
|
+
line-height: 1.5;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.pricing-card__features {
|
|
184
|
+
list-style: none;
|
|
185
|
+
padding: 0;
|
|
186
|
+
margin: 0;
|
|
187
|
+
display: flex;
|
|
188
|
+
flex-direction: column;
|
|
189
|
+
gap: 0.6rem;
|
|
190
|
+
|
|
191
|
+
flex-grow: 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.pricing-card__feature {
|
|
195
|
+
color: var(--_feature-color);
|
|
196
|
+
padding-left: 1.6rem;
|
|
197
|
+
position: relative;
|
|
198
|
+
|
|
199
|
+
&::before {
|
|
200
|
+
content: "✓";
|
|
201
|
+
position: absolute;
|
|
202
|
+
left: 0;
|
|
203
|
+
color: var(--teal-06);
|
|
204
|
+
font-weight: bold;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.pricing-card__cta {
|
|
209
|
+
align-self: flex-start;
|
|
210
|
+
|
|
211
|
+
:deep(.input-button-core) {
|
|
212
|
+
padding: var(--_cta-padding);
|
|
213
|
+
background-color: var(--_cta-bg);
|
|
214
|
+
color: var(--_cta-text);
|
|
215
|
+
border-radius: var(--_cta-border-radius);
|
|
216
|
+
font-weight: 600;
|
|
217
|
+
|
|
218
|
+
&:hover:not([aria-disabled="true"]) {
|
|
219
|
+
background-color: var(--_cta-bg-hover);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
</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
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import PricingCard from "../PricingCard.vue";
|
|
4
|
+
|
|
5
|
+
describe("PricingCard", () => {
|
|
6
|
+
it("renders plan name, price, and CTA", () => {
|
|
7
|
+
const wrapper = mount(PricingCard, {
|
|
8
|
+
props: {
|
|
9
|
+
planName: "Single Site",
|
|
10
|
+
price: 99,
|
|
11
|
+
billingPeriod: "one-time",
|
|
12
|
+
ctaText: "Get started",
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
expect(wrapper.text()).toContain("Single Site");
|
|
17
|
+
expect(wrapper.text()).toContain("$99");
|
|
18
|
+
expect(wrapper.text()).toContain("one-time");
|
|
19
|
+
expect(wrapper.text()).toContain("Get started");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders features from props", () => {
|
|
23
|
+
const features = ["Feature 1", "Feature 2", "Feature 3"];
|
|
24
|
+
const wrapper = mount(PricingCard, {
|
|
25
|
+
props: {
|
|
26
|
+
planName: "Test Plan",
|
|
27
|
+
price: 100,
|
|
28
|
+
features,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
features.forEach((feature) => {
|
|
33
|
+
expect(wrapper.text()).toContain(feature);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders description when provided", () => {
|
|
38
|
+
const description = "This is a great plan";
|
|
39
|
+
const wrapper = mount(PricingCard, {
|
|
40
|
+
props: {
|
|
41
|
+
planName: "Test Plan",
|
|
42
|
+
price: 100,
|
|
43
|
+
description,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(wrapper.text()).toContain(description);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("applies highlighted class when isHighlighted is true", () => {
|
|
51
|
+
const wrapper = mount(PricingCard, {
|
|
52
|
+
props: {
|
|
53
|
+
planName: "Test Plan",
|
|
54
|
+
price: 100,
|
|
55
|
+
isHighlighted: true,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(wrapper.classes()).toContain("is-highlighted");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("displays badge when highlighted", () => {
|
|
63
|
+
const wrapper = mount(PricingCard, {
|
|
64
|
+
props: {
|
|
65
|
+
planName: "Test Plan",
|
|
66
|
+
price: 100,
|
|
67
|
+
isHighlighted: true,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(wrapper.text()).toContain("Most Popular");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("emits select event with plan name on CTA click", async () => {
|
|
75
|
+
const wrapper = mount(PricingCard, {
|
|
76
|
+
props: {
|
|
77
|
+
planName: "Test Plan",
|
|
78
|
+
price: 100,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const button = wrapper.find("button");
|
|
83
|
+
await button.trigger("click");
|
|
84
|
+
|
|
85
|
+
expect(wrapper.emitted("select")).toBeTruthy();
|
|
86
|
+
expect(wrapper.emitted("select")?.[0]).toEqual(["Test Plan"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("disables CTA when ctaDisabled is true", () => {
|
|
90
|
+
const wrapper = mount(PricingCard, {
|
|
91
|
+
props: {
|
|
92
|
+
planName: "Test Plan",
|
|
93
|
+
price: 100,
|
|
94
|
+
ctaDisabled: true,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const button = wrapper.find("button");
|
|
99
|
+
expect(button.attributes("disabled")).toBeDefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("renders custom features via slot", () => {
|
|
103
|
+
const wrapper = mount(PricingCard, {
|
|
104
|
+
props: {
|
|
105
|
+
planName: "Test Plan",
|
|
106
|
+
price: 100,
|
|
107
|
+
},
|
|
108
|
+
slots: {
|
|
109
|
+
features: '<li>Custom Feature 1</li><li>Custom Feature 2</li>',
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(wrapper.text()).toContain("Custom Feature 1");
|
|
114
|
+
expect(wrapper.text()).toContain("Custom Feature 2");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("uses semantic article tag by default", () => {
|
|
118
|
+
const wrapper = mount(PricingCard, {
|
|
119
|
+
props: {
|
|
120
|
+
planName: "Test Plan",
|
|
121
|
+
price: 100,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(wrapper.element.tagName).toBe("ARTICLE");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("allows custom tag via prop", () => {
|
|
129
|
+
const wrapper = mount(PricingCard, {
|
|
130
|
+
props: {
|
|
131
|
+
planName: "Test Plan",
|
|
132
|
+
price: 100,
|
|
133
|
+
tag: "section",
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(wrapper.element.tagName).toBe("SECTION");
|
|
138
|
+
});
|
|
139
|
+
});
|
package/nuxt.config.ts
CHANGED
|
@@ -123,7 +123,12 @@ export default defineNuxtConfig({
|
|
|
123
123
|
},
|
|
124
124
|
vite: {
|
|
125
125
|
optimizeDeps: {
|
|
126
|
-
include: [
|
|
126
|
+
include: [
|
|
127
|
+
"@oddbird/css-anchor-positioning",
|
|
128
|
+
"@vue/devtools-core",
|
|
129
|
+
"@vue/devtools-kit",
|
|
130
|
+
...(isStandalone ? ["vue-qrcode-reader"] : []),
|
|
131
|
+
],
|
|
127
132
|
},
|
|
128
133
|
},
|
|
129
134
|
vue: {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "srcdev-nuxt-components",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "9.2.
|
|
4
|
+
"version": "9.2.3",
|
|
5
5
|
"main": "nuxt.config.ts",
|
|
6
6
|
"types": "types.d.ts",
|
|
7
7
|
"license": "MIT",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
".claude/",
|
|
35
|
+
".vscode/",
|
|
35
36
|
"app/",
|
|
36
37
|
"modules/",
|
|
37
38
|
"scripts/",
|