@r2digisolutions/components 0.19.7 → 0.19.9
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/components/molecules/Form/Form.svelte +1 -0
- package/dist/components/molecules/UserMenu/UserMenu.svelte +193 -84
- package/dist/components/organisms/Dialog/Dialog.svelte +7 -2
- package/dist/components/organisms/PricingTable/PricingTable.stories.d.ts +1 -0
- package/dist/components/organisms/PricingTable/PricingTable.stories.js +12 -1
- package/dist/components/organisms/PricingTable/PricingTable.svelte +214 -136
- package/dist/components/organisms/PricingTable/PricingTable.svelte.d.ts +10 -2
- package/dist/components/organisms/PricingTable/PricingTableStory.svelte +9 -2
- package/dist/components/organisms/Sidebar/Sidebar.svelte +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import { on } from 'svelte/events';
|
|
3
4
|
import Avatar from '../../atoms/Avatar/Avatar.svelte';
|
|
4
5
|
import Badge from '../../atoms/Badge/Badge.svelte';
|
|
5
6
|
import StatusDot from '../../atoms/StatusDot/StatusDot.svelte';
|
|
7
|
+
import { createId } from '../../../utils/id.js';
|
|
6
8
|
|
|
7
9
|
export interface UserMenuItem {
|
|
8
10
|
id: string;
|
|
@@ -74,7 +76,29 @@
|
|
|
74
76
|
onopenchange
|
|
75
77
|
}: UserMenuProps = $props();
|
|
76
78
|
|
|
77
|
-
let
|
|
79
|
+
let triggerEl = $state<HTMLButtonElement | null>(null);
|
|
80
|
+
let menuEl = $state<HTMLDivElement | null>(null);
|
|
81
|
+
let menuStyle = $state('');
|
|
82
|
+
let menuId = $state('');
|
|
83
|
+
|
|
84
|
+
$effect(() => {
|
|
85
|
+
if (!menuId) menuId = createId('user-menu');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Declarative invoker avoids the light-dismiss ↔ click race:
|
|
90
|
+
* without an associated source, pointerup closes and click reopens.
|
|
91
|
+
*/
|
|
92
|
+
$effect(() => {
|
|
93
|
+
const btn = triggerEl;
|
|
94
|
+
const menu = menuEl;
|
|
95
|
+
if (!btn || !menu) return;
|
|
96
|
+
btn.popoverTargetElement = menu;
|
|
97
|
+
btn.popoverTargetAction = 'toggle';
|
|
98
|
+
return () => {
|
|
99
|
+
btn.popoverTargetElement = null;
|
|
100
|
+
};
|
|
101
|
+
});
|
|
78
102
|
|
|
79
103
|
const avatarSize = $derived(size === 'sm' ? 'sm' : size === 'lg' ? 'lg' : 'md');
|
|
80
104
|
const statusLabel = $derived(
|
|
@@ -94,39 +118,111 @@
|
|
|
94
118
|
onopenchange?.(next);
|
|
95
119
|
}
|
|
96
120
|
|
|
97
|
-
function
|
|
98
|
-
|
|
121
|
+
function positionMenu() {
|
|
122
|
+
if (!triggerEl || !menuEl) return;
|
|
123
|
+
if (!menuEl.matches(':popover-open')) return;
|
|
124
|
+
|
|
125
|
+
const rect = triggerEl.getBoundingClientRect();
|
|
126
|
+
const gap = 8;
|
|
127
|
+
const margin = 8;
|
|
128
|
+
const vv = window.visualViewport;
|
|
129
|
+
const viewW = vv?.width ?? window.innerWidth;
|
|
130
|
+
const viewH = vv?.height ?? window.innerHeight;
|
|
131
|
+
const viewLeft = vv?.offsetLeft ?? 0;
|
|
132
|
+
const viewTop = vv?.offsetTop ?? 0;
|
|
133
|
+
|
|
134
|
+
const width = Math.min(Math.max(rect.width, 280), viewW - margin * 2);
|
|
135
|
+
const height = menuEl.offsetHeight || 280;
|
|
136
|
+
|
|
137
|
+
const spaceBelow = viewTop + viewH - rect.bottom - gap - margin;
|
|
138
|
+
const spaceAbove = rect.top - viewTop - gap - margin;
|
|
139
|
+
|
|
140
|
+
let place: 'top' | 'bottom' = side;
|
|
141
|
+
if (side === 'bottom' && height > spaceBelow && spaceAbove > spaceBelow) place = 'top';
|
|
142
|
+
else if (side === 'top' && height > spaceAbove && spaceBelow > spaceAbove) place = 'bottom';
|
|
143
|
+
|
|
144
|
+
let top: number;
|
|
145
|
+
if (place === 'bottom') {
|
|
146
|
+
top = rect.bottom + gap;
|
|
147
|
+
if (top + height > viewTop + viewH - margin) {
|
|
148
|
+
top = Math.max(viewTop + margin, viewTop + viewH - margin - height);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
top = rect.top - gap - height;
|
|
152
|
+
if (top < viewTop + margin) top = viewTop + margin;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let left = align === 'end' ? rect.right - width : rect.left;
|
|
156
|
+
left = Math.min(Math.max(left, viewLeft + margin), viewLeft + viewW - margin - width);
|
|
157
|
+
|
|
158
|
+
menuStyle = [
|
|
159
|
+
`top:${top}px`,
|
|
160
|
+
'bottom:auto',
|
|
161
|
+
`left:${left}px`,
|
|
162
|
+
'right:auto',
|
|
163
|
+
`width:${width}px`,
|
|
164
|
+
'height:auto',
|
|
165
|
+
'max-height:none'
|
|
166
|
+
].join(';');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function closeMenu() {
|
|
170
|
+
if (menuEl?.matches(':popover-open')) menuEl.hidePopover();
|
|
99
171
|
}
|
|
100
172
|
|
|
101
173
|
function select(item: UserMenuItem) {
|
|
102
174
|
if (item.disabled || item.separator) return;
|
|
103
175
|
onselect?.(item.id, item);
|
|
104
|
-
|
|
176
|
+
closeMenu();
|
|
105
177
|
}
|
|
106
178
|
|
|
107
|
-
function
|
|
108
|
-
if (
|
|
109
|
-
|
|
179
|
+
function handleBeforeToggle(event: ToggleEvent) {
|
|
180
|
+
if (event.newState === 'open') {
|
|
181
|
+
queueMicrotask(() => {
|
|
182
|
+
positionMenu();
|
|
183
|
+
requestAnimationFrame(() => positionMenu());
|
|
184
|
+
});
|
|
185
|
+
}
|
|
110
186
|
}
|
|
111
187
|
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
|
|
188
|
+
function handleToggle(event: ToggleEvent) {
|
|
189
|
+
const next = event.newState === 'open';
|
|
190
|
+
setOpen(next);
|
|
191
|
+
if (next) {
|
|
192
|
+
queueMicrotask(() => {
|
|
193
|
+
positionMenu();
|
|
194
|
+
requestAnimationFrame(() => positionMenu());
|
|
195
|
+
});
|
|
196
|
+
}
|
|
115
197
|
}
|
|
116
198
|
|
|
117
199
|
$effect(() => {
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
200
|
+
if (!open) return;
|
|
201
|
+
let frame = 0;
|
|
202
|
+
const reposition = (event?: Event) => {
|
|
203
|
+
const target = event?.target;
|
|
204
|
+
if (target instanceof Node && menuEl?.contains(target)) return;
|
|
205
|
+
cancelAnimationFrame(frame);
|
|
206
|
+
frame = requestAnimationFrame(() => positionMenu());
|
|
207
|
+
};
|
|
208
|
+
const offScroll = on(window, 'scroll', reposition, { capture: true, passive: true });
|
|
209
|
+
const offResize = on(window, 'resize', reposition);
|
|
210
|
+
const offVisual =
|
|
211
|
+
typeof window.visualViewport !== 'undefined' && window.visualViewport
|
|
212
|
+
? on(window.visualViewport, 'resize', reposition)
|
|
213
|
+
: () => {};
|
|
121
214
|
return () => {
|
|
122
|
-
|
|
123
|
-
|
|
215
|
+
cancelAnimationFrame(frame);
|
|
216
|
+
offScroll();
|
|
217
|
+
offResize();
|
|
218
|
+
offVisual();
|
|
124
219
|
};
|
|
125
220
|
});
|
|
126
221
|
</script>
|
|
127
222
|
|
|
128
|
-
<div
|
|
223
|
+
<div class={['relative flex w-full min-w-0', className]}>
|
|
129
224
|
<button
|
|
225
|
+
bind:this={triggerEl}
|
|
130
226
|
type="button"
|
|
131
227
|
class={[
|
|
132
228
|
'inline-flex w-full min-w-0 max-w-full items-center text-left transition-colors',
|
|
@@ -141,7 +237,7 @@
|
|
|
141
237
|
]}
|
|
142
238
|
aria-haspopup="menu"
|
|
143
239
|
aria-expanded={open}
|
|
144
|
-
|
|
240
|
+
aria-controls={menuId || undefined}
|
|
145
241
|
>
|
|
146
242
|
<span class="shrink-0">
|
|
147
243
|
<Avatar {src} {name} size={avatarSize} {status} />
|
|
@@ -170,77 +266,90 @@
|
|
|
170
266
|
{/if}
|
|
171
267
|
</button>
|
|
172
268
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
269
|
+
<div
|
|
270
|
+
bind:this={menuEl}
|
|
271
|
+
id={menuId}
|
|
272
|
+
popover="auto"
|
|
273
|
+
role="menu"
|
|
274
|
+
tabindex={-1}
|
|
275
|
+
aria-label="User menu"
|
|
276
|
+
style={menuStyle}
|
|
277
|
+
ontoggle={handleToggle}
|
|
278
|
+
onbeforetoggle={handleBeforeToggle}
|
|
279
|
+
class="user-menu m-0 overflow-hidden rounded-2xl border border-border bg-surface-elevated shadow-xl inset-auto outline-none"
|
|
280
|
+
>
|
|
281
|
+
{#if header}
|
|
282
|
+
{@render header()}
|
|
283
|
+
{:else}
|
|
284
|
+
<div class="border-b border-border bg-surface-overlay/50 px-3.5 py-3">
|
|
285
|
+
<div class="flex items-start gap-3">
|
|
286
|
+
<Avatar {src} {name} size="lg" {status} />
|
|
287
|
+
<div class="min-w-0 flex-1 pt-0.5">
|
|
288
|
+
<p class="truncate text-sm font-semibold text-primary">{name}</p>
|
|
289
|
+
{#if email}
|
|
290
|
+
<p class="truncate text-xs text-muted">{email}</p>
|
|
291
|
+
{/if}
|
|
292
|
+
<div class="mt-2 flex flex-wrap items-center gap-2">
|
|
293
|
+
{#if status && statusLabel}
|
|
294
|
+
<StatusDot {status} size="sm" showLabel label={statusLabel} />
|
|
295
|
+
{/if}
|
|
296
|
+
{#if role}
|
|
297
|
+
<span
|
|
298
|
+
class="rounded-md bg-surface-elevated px-1.5 py-0.5 text-[10px] font-medium text-secondary ring-1 ring-border"
|
|
299
|
+
>
|
|
300
|
+
{role}
|
|
301
|
+
</span>
|
|
302
|
+
{/if}
|
|
303
|
+
{#if plan}
|
|
304
|
+
<Badge variant="primary" size="sm">{plan}</Badge>
|
|
193
305
|
{/if}
|
|
194
|
-
<div class="mt-2 flex flex-wrap items-center gap-2">
|
|
195
|
-
{#if status && statusLabel}
|
|
196
|
-
<StatusDot {status} size="sm" showLabel label={statusLabel} />
|
|
197
|
-
{/if}
|
|
198
|
-
{#if role}
|
|
199
|
-
<span class="rounded-md bg-surface-elevated px-1.5 py-0.5 text-[10px] font-medium text-secondary ring-1 ring-border">
|
|
200
|
-
{role}
|
|
201
|
-
</span>
|
|
202
|
-
{/if}
|
|
203
|
-
{#if plan}
|
|
204
|
-
<Badge variant="primary" size="sm">{plan}</Badge>
|
|
205
|
-
{/if}
|
|
206
|
-
</div>
|
|
207
306
|
</div>
|
|
208
307
|
</div>
|
|
209
308
|
</div>
|
|
210
|
-
{/if}
|
|
211
|
-
|
|
212
|
-
<div class="p-1.5">
|
|
213
|
-
{#each items as item (item.id)}
|
|
214
|
-
{#if item.separator}
|
|
215
|
-
<div class="my-1.5 border-t border-border" role="separator"></div>
|
|
216
|
-
{:else}
|
|
217
|
-
<button
|
|
218
|
-
type="button"
|
|
219
|
-
role="menuitem"
|
|
220
|
-
disabled={item.disabled}
|
|
221
|
-
onclick={() => select(item)}
|
|
222
|
-
class={[
|
|
223
|
-
'flex w-full items-start gap-2 rounded-xl px-2.5 py-2 text-left transition-colors',
|
|
224
|
-
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
|
|
225
|
-
item.destructive
|
|
226
|
-
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
|
|
227
|
-
: 'text-primary hover:bg-surface-overlay',
|
|
228
|
-
item.disabled && 'cursor-not-allowed opacity-40'
|
|
229
|
-
]}
|
|
230
|
-
>
|
|
231
|
-
<span class="min-w-0 flex-1">
|
|
232
|
-
<span class="block text-sm font-medium">{item.label}</span>
|
|
233
|
-
{#if item.description}
|
|
234
|
-
<span class="block text-xs text-muted">{item.description}</span>
|
|
235
|
-
{/if}
|
|
236
|
-
</span>
|
|
237
|
-
{#if item.shortcut}
|
|
238
|
-
<span class="mt-0.5 shrink-0 font-mono text-[10px] text-muted">{item.shortcut}</span>
|
|
239
|
-
{/if}
|
|
240
|
-
</button>
|
|
241
|
-
{/if}
|
|
242
|
-
{/each}
|
|
243
309
|
</div>
|
|
310
|
+
{/if}
|
|
311
|
+
|
|
312
|
+
<div class="p-1.5">
|
|
313
|
+
{#each items as item (item.id)}
|
|
314
|
+
{#if item.separator}
|
|
315
|
+
<div class="my-1.5 border-t border-border" role="separator"></div>
|
|
316
|
+
{:else}
|
|
317
|
+
<button
|
|
318
|
+
type="button"
|
|
319
|
+
role="menuitem"
|
|
320
|
+
disabled={item.disabled}
|
|
321
|
+
onclick={() => select(item)}
|
|
322
|
+
class={[
|
|
323
|
+
'flex w-full items-start gap-2 rounded-xl px-2.5 py-2 text-left transition-colors',
|
|
324
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
|
|
325
|
+
item.destructive
|
|
326
|
+
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
|
|
327
|
+
: 'text-primary hover:bg-surface-overlay',
|
|
328
|
+
item.disabled && 'cursor-not-allowed opacity-40'
|
|
329
|
+
]}
|
|
330
|
+
>
|
|
331
|
+
<span class="min-w-0 flex-1">
|
|
332
|
+
<span class="block text-sm font-medium">{item.label}</span>
|
|
333
|
+
{#if item.description}
|
|
334
|
+
<span class="block text-xs text-muted">{item.description}</span>
|
|
335
|
+
{/if}
|
|
336
|
+
</span>
|
|
337
|
+
{#if item.shortcut}
|
|
338
|
+
<span class="mt-0.5 shrink-0 font-mono text-[10px] text-muted">{item.shortcut}</span>
|
|
339
|
+
{/if}
|
|
340
|
+
</button>
|
|
341
|
+
{/if}
|
|
342
|
+
{/each}
|
|
244
343
|
</div>
|
|
245
|
-
|
|
344
|
+
</div>
|
|
246
345
|
</div>
|
|
346
|
+
|
|
347
|
+
<style>
|
|
348
|
+
.user-menu {
|
|
349
|
+
position: fixed;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.user-menu:popover-open {
|
|
353
|
+
display: block;
|
|
354
|
+
}
|
|
355
|
+
</style>
|
|
@@ -279,8 +279,13 @@
|
|
|
279
279
|
{#if children}
|
|
280
280
|
<div
|
|
281
281
|
class={[
|
|
282
|
-
'px-5 py-2 text-sm text-primary',
|
|
283
|
-
scrollable &&
|
|
282
|
+
'min-h-0 flex-1 px-5 py-2 text-sm text-primary',
|
|
283
|
+
scrollable &&
|
|
284
|
+
(size === 'full'
|
|
285
|
+
? 'max-h-[calc(100vh-11rem)] overflow-y-auto'
|
|
286
|
+
: size === 'xl'
|
|
287
|
+
? 'max-h-[min(70vh,36rem)] overflow-y-auto'
|
|
288
|
+
: 'max-h-[min(60vh,28rem)] overflow-y-auto')
|
|
284
289
|
]}
|
|
285
290
|
>
|
|
286
291
|
{@render children()}
|
|
@@ -62,6 +62,7 @@ export declare const Bento: Story;
|
|
|
62
62
|
export declare const Compact: Story;
|
|
63
63
|
export declare const Split: Story;
|
|
64
64
|
export declare const Table: Story;
|
|
65
|
+
export declare const ListCompare: Story;
|
|
65
66
|
export declare const CardsPlusComparison: Story;
|
|
66
67
|
export declare const TwoPlans: Story;
|
|
67
68
|
export declare const FourPlans: Story;
|
|
@@ -9,7 +9,17 @@ const meta = {
|
|
|
9
9
|
argTypes: {
|
|
10
10
|
layout: {
|
|
11
11
|
control: 'select',
|
|
12
|
-
options: [
|
|
12
|
+
options: [
|
|
13
|
+
'grid',
|
|
14
|
+
'horizontal',
|
|
15
|
+
'vertical',
|
|
16
|
+
'bento',
|
|
17
|
+
'compact',
|
|
18
|
+
'split',
|
|
19
|
+
'table',
|
|
20
|
+
'list',
|
|
21
|
+
'compare'
|
|
22
|
+
]
|
|
13
23
|
},
|
|
14
24
|
columns: { control: 'select', options: [2, 3, 4] },
|
|
15
25
|
planSet: {
|
|
@@ -39,6 +49,7 @@ export const Bento = { args: { layout: 'bento', planSet: 'bento' } };
|
|
|
39
49
|
export const Compact = { args: { layout: 'compact', planSet: 'four', columns: 4 } };
|
|
40
50
|
export const Split = { args: { layout: 'split' } };
|
|
41
51
|
export const Table = { args: { layout: 'table', planSet: 'comparison' } };
|
|
52
|
+
export const ListCompare = { args: { layout: 'list', planSet: 'comparison' } };
|
|
42
53
|
export const CardsPlusComparison = {
|
|
43
54
|
args: { layout: 'grid', showComparison: true, planSet: 'comparison' }
|
|
44
55
|
};
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
| 'bento'
|
|
7
7
|
| 'compact'
|
|
8
8
|
| 'split'
|
|
9
|
-
| 'table'
|
|
9
|
+
| 'table'
|
|
10
|
+
| 'list'
|
|
11
|
+
| 'compare';
|
|
10
12
|
|
|
11
13
|
export type PricingFeature =
|
|
12
14
|
| string
|
|
@@ -22,8 +24,12 @@
|
|
|
22
24
|
price: string;
|
|
23
25
|
/** Yearly price when billing toggle is yearly */
|
|
24
26
|
priceYearly?: string;
|
|
27
|
+
/** Struck-through list price (optional) */
|
|
28
|
+
compareAtPrice?: string;
|
|
25
29
|
period?: string;
|
|
26
30
|
periodYearly?: string;
|
|
31
|
+
/** Small hint under the price (e.g. savings) */
|
|
32
|
+
priceNote?: string;
|
|
27
33
|
description?: string;
|
|
28
34
|
features: PricingFeature[];
|
|
29
35
|
cta?: string;
|
|
@@ -45,6 +51,8 @@
|
|
|
45
51
|
|
|
46
52
|
<script lang="ts">
|
|
47
53
|
import type { Snippet } from 'svelte';
|
|
54
|
+
import Check from '@lucide/svelte/icons/check';
|
|
55
|
+
import X from '@lucide/svelte/icons/x';
|
|
48
56
|
import Button from '../../atoms/Button/Button.svelte';
|
|
49
57
|
import Badge from '../../atoms/Badge/Badge.svelte';
|
|
50
58
|
import SegmentedControl from '../../molecules/SegmentedControl/SegmentedControl.svelte';
|
|
@@ -61,7 +69,8 @@
|
|
|
61
69
|
/** Show monthly/yearly toggle when plans have priceYearly */
|
|
62
70
|
showBillingToggle?: boolean;
|
|
63
71
|
billingPeriod?: 'monthly' | 'yearly';
|
|
64
|
-
|
|
72
|
+
billingLabels?: { monthly?: string; yearly?: string };
|
|
73
|
+
/** Explicit feature matrix for compare/list/table (auto-built when omitted) */
|
|
65
74
|
comparisonRows?: PricingComparisonRow[];
|
|
66
75
|
/** Append comparison matrix below card layouts */
|
|
67
76
|
showComparison?: boolean;
|
|
@@ -81,6 +90,7 @@
|
|
|
81
90
|
maxFeatures,
|
|
82
91
|
showBillingToggle = false,
|
|
83
92
|
billingPeriod = $bindable<'monthly' | 'yearly'>('monthly'),
|
|
93
|
+
billingLabels,
|
|
84
94
|
comparisonRows,
|
|
85
95
|
showComparison = false,
|
|
86
96
|
class: className = '',
|
|
@@ -89,6 +99,10 @@
|
|
|
89
99
|
onbillingperiodchange
|
|
90
100
|
}: PricingTableProps = $props();
|
|
91
101
|
|
|
102
|
+
const isCompareLayout = $derived(
|
|
103
|
+
layout === 'table' || layout === 'list' || layout === 'compare'
|
|
104
|
+
);
|
|
105
|
+
|
|
92
106
|
const resolvedColumns = $derived.by(() => {
|
|
93
107
|
if (columns) return columns;
|
|
94
108
|
const n = plans.length;
|
|
@@ -138,12 +152,16 @@
|
|
|
138
152
|
}
|
|
139
153
|
|
|
140
154
|
function displayPeriod(plan: PricingPlan) {
|
|
141
|
-
if (billingPeriod === 'yearly') {
|
|
155
|
+
if (billingPeriod === 'yearly' && plan.priceYearly) {
|
|
142
156
|
return normalizePeriod(plan.periodYearly ?? 'yr');
|
|
143
157
|
}
|
|
144
158
|
return normalizePeriod(plan.period);
|
|
145
159
|
}
|
|
146
160
|
|
|
161
|
+
const canToggleBilling = $derived(
|
|
162
|
+
showBillingToggle && plans.some((p) => Boolean(p.priceYearly))
|
|
163
|
+
);
|
|
164
|
+
|
|
147
165
|
function featureList(plan: PricingPlan) {
|
|
148
166
|
const list = plan.features.map((f) =>
|
|
149
167
|
typeof f === 'string' ? { label: f, included: true as boolean | undefined } : f
|
|
@@ -189,7 +207,7 @@
|
|
|
189
207
|
}));
|
|
190
208
|
});
|
|
191
209
|
|
|
192
|
-
const showMatrix = $derived(
|
|
210
|
+
const showMatrix = $derived(isCompareLayout || showComparison);
|
|
193
211
|
|
|
194
212
|
function select(id: string, disabled?: boolean) {
|
|
195
213
|
if (disabled) return;
|
|
@@ -221,8 +239,52 @@
|
|
|
221
239
|
billingPeriod = id as 'monthly' | 'yearly';
|
|
222
240
|
onbillingperiodchange?.(billingPeriod);
|
|
223
241
|
}
|
|
242
|
+
|
|
243
|
+
function planColClass(plan: PricingPlan) {
|
|
244
|
+
const selected = selectedId === plan.id;
|
|
245
|
+
return [
|
|
246
|
+
plan.featured && 'bg-brand-50/50 dark:bg-brand-950/20',
|
|
247
|
+
selected && 'bg-brand-50/80 ring-inset ring-2 ring-brand-500/35 dark:bg-brand-950/30'
|
|
248
|
+
];
|
|
249
|
+
}
|
|
224
250
|
</script>
|
|
225
251
|
|
|
252
|
+
{#snippet priceBlock(plan: PricingPlan, opts?: { large?: boolean; dense?: boolean; align?: 'left' | 'center' | 'right' })}
|
|
253
|
+
{@const large = opts?.large ?? false}
|
|
254
|
+
{@const dense = opts?.dense ?? false}
|
|
255
|
+
{@const align = opts?.align ?? 'left'}
|
|
256
|
+
<div
|
|
257
|
+
class={[
|
|
258
|
+
align === 'center' && 'text-center',
|
|
259
|
+
align === 'right' && 'text-right'
|
|
260
|
+
]}
|
|
261
|
+
>
|
|
262
|
+
{#if plan.compareAtPrice}
|
|
263
|
+
<p class={['text-muted line-through', dense ? 'text-[11px]' : 'text-xs']}>
|
|
264
|
+
{plan.compareAtPrice}
|
|
265
|
+
</p>
|
|
266
|
+
{/if}
|
|
267
|
+
<p>
|
|
268
|
+
<span
|
|
269
|
+
class={[
|
|
270
|
+
'font-semibold text-primary',
|
|
271
|
+
large ? 'text-4xl' : dense ? 'text-2xl' : 'text-3xl'
|
|
272
|
+
]}
|
|
273
|
+
>
|
|
274
|
+
{displayPrice(plan)}
|
|
275
|
+
</span>
|
|
276
|
+
{#if displayPeriod(plan)}
|
|
277
|
+
<span class="text-sm text-muted">{displayPeriod(plan)}</span>
|
|
278
|
+
{/if}
|
|
279
|
+
</p>
|
|
280
|
+
{#if plan.priceNote}
|
|
281
|
+
<p class={['mt-1 font-medium text-brand-600 dark:text-brand-400', dense ? 'text-[11px]' : 'text-xs']}>
|
|
282
|
+
{plan.priceNote}
|
|
283
|
+
</p>
|
|
284
|
+
{/if}
|
|
285
|
+
</div>
|
|
286
|
+
{/snippet}
|
|
287
|
+
|
|
226
288
|
{#snippet featureRows(plan: PricingPlan, dense = false)}
|
|
227
289
|
{@const { visible, more } = featureList(plan)}
|
|
228
290
|
{#if showFeatures && visible.length}
|
|
@@ -288,15 +350,8 @@
|
|
|
288
350
|
</div>
|
|
289
351
|
{/if}
|
|
290
352
|
</div>
|
|
291
|
-
<div class="flex shrink-0 flex-col items-stretch gap-3 sm:w-
|
|
292
|
-
|
|
293
|
-
<span class={['font-semibold text-primary', large ? 'text-4xl' : 'text-2xl']}>
|
|
294
|
-
{displayPrice(plan)}
|
|
295
|
-
</span>
|
|
296
|
-
{#if displayPeriod(plan)}
|
|
297
|
-
<span class="text-sm text-muted">{displayPeriod(plan)}</span>
|
|
298
|
-
{/if}
|
|
299
|
-
</p>
|
|
353
|
+
<div class="flex shrink-0 flex-col items-stretch gap-3 sm:w-44 sm:items-stretch">
|
|
354
|
+
{@render priceBlock(plan, { large, dense: true, align: 'right' })}
|
|
300
355
|
<Button
|
|
301
356
|
variant={plan.featured || selectedId === plan.id ? 'primary' : 'secondary'}
|
|
302
357
|
size="sm"
|
|
@@ -328,19 +383,9 @@
|
|
|
328
383
|
{/if}
|
|
329
384
|
</div>
|
|
330
385
|
|
|
331
|
-
<
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
'font-semibold text-primary',
|
|
335
|
-
large ? 'text-4xl' : dense ? 'text-2xl' : 'text-3xl'
|
|
336
|
-
]}
|
|
337
|
-
>
|
|
338
|
-
{displayPrice(plan)}
|
|
339
|
-
</span>
|
|
340
|
-
{#if displayPeriod(plan)}
|
|
341
|
-
<span class="text-sm text-muted">{displayPeriod(plan)}</span>
|
|
342
|
-
{/if}
|
|
343
|
-
</p>
|
|
386
|
+
<div class={dense ? 'mb-3' : 'mb-4'}>
|
|
387
|
+
{@render priceBlock(plan, { large, dense })}
|
|
388
|
+
</div>
|
|
344
389
|
|
|
345
390
|
<div class="mb-auto flex-1">
|
|
346
391
|
{@render featureRows(plan, dense)}
|
|
@@ -364,13 +409,16 @@
|
|
|
364
409
|
{#snippet matrixCell(value: boolean | string)}
|
|
365
410
|
{#if typeof value === 'boolean'}
|
|
366
411
|
{#if value}
|
|
367
|
-
<span
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
412
|
+
<span
|
|
413
|
+
class="inline-flex text-brand-600 dark:text-brand-400"
|
|
414
|
+
aria-label="Included"
|
|
415
|
+
>
|
|
416
|
+
<Check class="mx-auto h-4 w-4" strokeWidth={2.75} aria-hidden="true" />
|
|
371
417
|
</span>
|
|
372
418
|
{:else}
|
|
373
|
-
<span class="text-muted" aria-label="Not included"
|
|
419
|
+
<span class="inline-flex text-muted" aria-label="Not included">
|
|
420
|
+
<X class="mx-auto h-4 w-4" strokeWidth={2.25} aria-hidden="true" />
|
|
421
|
+
</span>
|
|
374
422
|
{/if}
|
|
375
423
|
{:else}
|
|
376
424
|
<span class="text-sm font-medium text-primary">{value}</span>
|
|
@@ -378,124 +426,150 @@
|
|
|
378
426
|
{/snippet}
|
|
379
427
|
|
|
380
428
|
{#snippet comparisonTable(withPricingHeader: boolean)}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
class="grid min-w-160 divide-x divide-border border-b border-border"
|
|
390
|
-
style:grid-template-columns={matrixCols}
|
|
391
|
-
>
|
|
392
|
-
<div class="bg-surface-overlay/30" aria-hidden="true"></div>
|
|
393
|
-
{#each plans as plan (plan.id)}
|
|
394
|
-
{@const badgeText = plan.badge ?? (plan.featured ? featuredBadgeLabel : undefined)}
|
|
395
|
-
<div
|
|
396
|
-
class={[
|
|
397
|
-
'flex flex-col items-center px-4 py-6 text-center',
|
|
398
|
-
plan.featured && 'bg-brand-50/70 dark:bg-brand-950/25'
|
|
399
|
-
]}
|
|
400
|
-
>
|
|
401
|
-
<div class="flex min-h-6 flex-wrap items-center justify-center gap-1.5">
|
|
402
|
-
<span class="text-sm font-semibold text-primary">{plan.name}</span>
|
|
403
|
-
{#if badgeText}
|
|
404
|
-
<Badge size="sm" variant="primary" class="whitespace-nowrap">{badgeText}</Badge>
|
|
405
|
-
{/if}
|
|
406
|
-
</div>
|
|
407
|
-
<p class="mt-1 min-h-8 max-w-44 text-xs leading-snug text-secondary">
|
|
408
|
-
{plan.description ?? '\u00a0'}
|
|
409
|
-
</p>
|
|
410
|
-
<p class="mt-3">
|
|
411
|
-
<span class="text-3xl font-semibold tracking-tight text-primary">
|
|
412
|
-
{displayPrice(plan)}
|
|
413
|
-
</span>
|
|
414
|
-
{#if displayPeriod(plan)}
|
|
415
|
-
<span class="text-xs text-muted">{displayPeriod(plan)}</span>
|
|
416
|
-
{/if}
|
|
417
|
-
</p>
|
|
418
|
-
<Button
|
|
419
|
-
variant={plan.featured || selectedId === plan.id ? 'primary' : 'secondary'}
|
|
420
|
-
size="sm"
|
|
421
|
-
class="mt-4 w-full max-w-40"
|
|
422
|
-
disabled={plan.disabled}
|
|
423
|
-
onclick={() => select(plan.id, plan.disabled)}
|
|
429
|
+
<div class="w-full overflow-x-auto rounded-xl border border-border bg-surface-elevated">
|
|
430
|
+
<table class="w-full min-w-160 border-collapse text-sm" aria-label="Plan feature comparison">
|
|
431
|
+
<thead class="sticky top-0 z-30">
|
|
432
|
+
{#if withPricingHeader}
|
|
433
|
+
<tr class="border-b border-border bg-surface-elevated shadow-sm">
|
|
434
|
+
<th
|
|
435
|
+
scope="col"
|
|
436
|
+
class="sticky left-0 z-40 bg-surface-elevated px-4 py-4 text-left align-bottom"
|
|
424
437
|
>
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
438
|
+
<span class="text-muted text-[11px] font-semibold tracking-wide uppercase">
|
|
439
|
+
Incluye
|
|
440
|
+
</span>
|
|
441
|
+
</th>
|
|
442
|
+
{#each plans as plan (plan.id)}
|
|
443
|
+
{@const badgeText =
|
|
444
|
+
plan.badge ?? (plan.featured ? featuredBadgeLabel : undefined)}
|
|
445
|
+
{@const selected = selectedId === plan.id}
|
|
446
|
+
<th
|
|
447
|
+
scope="col"
|
|
448
|
+
class={[
|
|
449
|
+
'min-w-40 px-3 py-4 text-center align-top font-normal',
|
|
450
|
+
planColClass(plan)
|
|
451
|
+
]}
|
|
452
|
+
>
|
|
453
|
+
<button
|
|
454
|
+
type="button"
|
|
455
|
+
class={[
|
|
456
|
+
'flex w-full flex-col items-center gap-2 rounded-xl px-2 py-2 text-center transition-colors',
|
|
457
|
+
selected && 'bg-brand-500/10',
|
|
458
|
+
!plan.disabled && 'hover:bg-surface-overlay/50',
|
|
459
|
+
plan.disabled && 'cursor-not-allowed opacity-50'
|
|
460
|
+
]}
|
|
461
|
+
disabled={plan.disabled}
|
|
462
|
+
aria-pressed={selected}
|
|
463
|
+
onclick={() => select(plan.id, plan.disabled)}
|
|
464
|
+
>
|
|
465
|
+
<div class="flex min-h-6 flex-wrap items-center justify-center gap-1.5">
|
|
466
|
+
<span class="text-sm font-semibold text-primary">{plan.name}</span>
|
|
467
|
+
{#if badgeText}
|
|
468
|
+
<Badge size="sm" variant="primary" class="whitespace-nowrap">
|
|
469
|
+
{badgeText}
|
|
470
|
+
</Badge>
|
|
471
|
+
{/if}
|
|
472
|
+
</div>
|
|
473
|
+
{#if plan.description}
|
|
474
|
+
<p class="line-clamp-2 max-w-44 text-xs leading-snug text-secondary">
|
|
475
|
+
{plan.description}
|
|
476
|
+
</p>
|
|
477
|
+
{/if}
|
|
478
|
+
{@render priceBlock(plan, { dense: true, align: 'center' })}
|
|
479
|
+
<span
|
|
480
|
+
class={[
|
|
481
|
+
'inline-flex h-8 w-full max-w-40 items-center justify-center rounded-lg px-3 text-xs font-medium',
|
|
482
|
+
selected || plan.featured
|
|
483
|
+
? 'bg-brand-600 text-white'
|
|
484
|
+
: 'bg-surface-overlay text-primary ring-1 ring-border'
|
|
485
|
+
]}
|
|
486
|
+
>
|
|
487
|
+
{plan.cta ?? 'Select'}
|
|
488
|
+
</span>
|
|
489
|
+
</button>
|
|
490
|
+
</th>
|
|
491
|
+
{/each}
|
|
492
|
+
</tr>
|
|
493
|
+
{:else}
|
|
494
|
+
<tr class="border-b border-border bg-surface/80">
|
|
495
|
+
<th
|
|
496
|
+
scope="col"
|
|
497
|
+
class="sticky left-0 z-40 bg-surface px-4 py-2.5 text-left"
|
|
498
|
+
></th>
|
|
499
|
+
{#each plans as plan (plan.id)}
|
|
500
|
+
<th
|
|
501
|
+
scope="col"
|
|
502
|
+
class={[
|
|
503
|
+
'px-3 py-2.5 text-center text-xs font-semibold text-secondary',
|
|
504
|
+
planColClass(plan)
|
|
505
|
+
]}
|
|
506
|
+
>
|
|
507
|
+
<button
|
|
508
|
+
type="button"
|
|
509
|
+
class="w-full rounded-md px-1 py-0.5 hover:bg-surface-overlay/60"
|
|
510
|
+
disabled={plan.disabled}
|
|
511
|
+
aria-pressed={selectedId === plan.id}
|
|
512
|
+
onclick={() => select(plan.id, plan.disabled)}
|
|
513
|
+
>
|
|
514
|
+
{plan.name}
|
|
515
|
+
</button>
|
|
516
|
+
</th>
|
|
517
|
+
{/each}
|
|
518
|
+
</tr>
|
|
519
|
+
{/if}
|
|
520
|
+
</thead>
|
|
521
|
+
<tbody>
|
|
522
|
+
{#each resolvedComparisonRows as row, ri (row.id)}
|
|
523
|
+
<tr
|
|
447
524
|
class={[
|
|
448
|
-
'
|
|
449
|
-
|
|
525
|
+
'border-b border-border last:border-b-0',
|
|
526
|
+
ri % 2 === 1 && 'bg-surface-overlay/25'
|
|
450
527
|
]}
|
|
451
|
-
role="columnheader"
|
|
452
528
|
>
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
{/each}
|
|
456
|
-
</div>
|
|
457
|
-
|
|
458
|
-
{#each resolvedComparisonRows as row, ri (row.id)}
|
|
459
|
-
<div
|
|
460
|
-
class={[
|
|
461
|
-
'grid divide-x divide-border border-b border-border last:border-b-0',
|
|
462
|
-
ri % 2 === 1 && 'bg-surface-overlay/20'
|
|
463
|
-
]}
|
|
464
|
-
style:grid-template-columns={matrixCols}
|
|
465
|
-
role="row"
|
|
466
|
-
>
|
|
467
|
-
<div
|
|
468
|
-
class="flex items-center px-4 py-3.5 text-sm font-medium text-primary"
|
|
469
|
-
role="rowheader"
|
|
470
|
-
>
|
|
471
|
-
{row.label}
|
|
472
|
-
</div>
|
|
473
|
-
{#each row.values as value, vi}
|
|
474
|
-
{@const plan = plans[vi]}
|
|
475
|
-
<div
|
|
529
|
+
<th
|
|
530
|
+
scope="row"
|
|
476
531
|
class={[
|
|
477
|
-
'
|
|
478
|
-
|
|
532
|
+
'sticky left-0 z-10 border-r border-border px-4 py-3 text-left text-sm font-medium text-primary',
|
|
533
|
+
ri % 2 === 1 ? 'bg-surface-overlay/40' : 'bg-surface-elevated'
|
|
479
534
|
]}
|
|
480
|
-
role="cell"
|
|
481
535
|
>
|
|
482
|
-
{
|
|
483
|
-
</
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
536
|
+
{row.label}
|
|
537
|
+
</th>
|
|
538
|
+
{#each row.values as value, vi (`${row.id}-${plans[vi]?.id ?? vi}`)}
|
|
539
|
+
{@const plan = plans[vi]}
|
|
540
|
+
<td
|
|
541
|
+
class={[
|
|
542
|
+
'px-3 py-3 text-center',
|
|
543
|
+
plan && planColClass(plan)
|
|
544
|
+
]}
|
|
545
|
+
>
|
|
546
|
+
{@render matrixCell(value)}
|
|
547
|
+
</td>
|
|
548
|
+
{/each}
|
|
549
|
+
</tr>
|
|
550
|
+
{:else}
|
|
551
|
+
<tr>
|
|
552
|
+
<td
|
|
553
|
+
class="text-muted px-4 py-8 text-center text-sm"
|
|
554
|
+
colspan={Math.max(plans.length, 1) + 1}
|
|
555
|
+
>
|
|
556
|
+
Sin características para comparar.
|
|
557
|
+
</td>
|
|
558
|
+
</tr>
|
|
559
|
+
{/each}
|
|
560
|
+
</tbody>
|
|
561
|
+
</table>
|
|
488
562
|
</div>
|
|
489
563
|
{/snippet}
|
|
490
564
|
|
|
491
565
|
<div class={['w-full space-y-4', className]}>
|
|
492
|
-
{#if
|
|
566
|
+
{#if canToggleBilling}
|
|
493
567
|
<div class="flex justify-center">
|
|
494
568
|
<SegmentedControl
|
|
495
569
|
size="sm"
|
|
496
570
|
items={[
|
|
497
|
-
{ id: 'monthly', label: 'Monthly' },
|
|
498
|
-
{ id: 'yearly', label: 'Yearly' }
|
|
571
|
+
{ id: 'monthly', label: billingLabels?.monthly ?? 'Monthly' },
|
|
572
|
+
{ id: 'yearly', label: billingLabels?.yearly ?? 'Yearly' }
|
|
499
573
|
]}
|
|
500
574
|
bind:value={billingPeriod}
|
|
501
575
|
onchange={onBillingChange}
|
|
@@ -503,7 +577,11 @@
|
|
|
503
577
|
</div>
|
|
504
578
|
{/if}
|
|
505
579
|
|
|
506
|
-
{#if
|
|
580
|
+
{#if plans.length === 0}
|
|
581
|
+
<p class="text-muted rounded-xl border border-border bg-surface-elevated px-4 py-8 text-center text-sm">
|
|
582
|
+
No hay tarifas disponibles para este periodo.
|
|
583
|
+
</p>
|
|
584
|
+
{:else if isCompareLayout}
|
|
507
585
|
{@render comparisonTable(true)}
|
|
508
586
|
{:else if layout === 'split' && featuredPlan}
|
|
509
587
|
<div class={containerClass} role="list">
|
|
@@ -553,7 +631,7 @@
|
|
|
553
631
|
</div>
|
|
554
632
|
{/if}
|
|
555
633
|
|
|
556
|
-
{#if showMatrix &&
|
|
634
|
+
{#if showMatrix && !isCompareLayout}
|
|
557
635
|
<div class="pt-2">
|
|
558
636
|
{@render comparisonTable(false)}
|
|
559
637
|
</div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type PricingLayout = 'grid' | 'horizontal' | 'vertical' | 'bento' | 'compact' | 'split' | 'table';
|
|
1
|
+
export type PricingLayout = 'grid' | 'horizontal' | 'vertical' | 'bento' | 'compact' | 'split' | 'table' | 'list' | 'compare';
|
|
2
2
|
export type PricingFeature = string | {
|
|
3
3
|
label: string;
|
|
4
4
|
included?: boolean;
|
|
@@ -10,8 +10,12 @@ export interface PricingPlan {
|
|
|
10
10
|
price: string;
|
|
11
11
|
/** Yearly price when billing toggle is yearly */
|
|
12
12
|
priceYearly?: string;
|
|
13
|
+
/** Struck-through list price (optional) */
|
|
14
|
+
compareAtPrice?: string;
|
|
13
15
|
period?: string;
|
|
14
16
|
periodYearly?: string;
|
|
17
|
+
/** Small hint under the price (e.g. savings) */
|
|
18
|
+
priceNote?: string;
|
|
15
19
|
description?: string;
|
|
16
20
|
features: PricingFeature[];
|
|
17
21
|
cta?: string;
|
|
@@ -41,7 +45,11 @@ interface PricingTableProps {
|
|
|
41
45
|
/** Show monthly/yearly toggle when plans have priceYearly */
|
|
42
46
|
showBillingToggle?: boolean;
|
|
43
47
|
billingPeriod?: 'monthly' | 'yearly';
|
|
44
|
-
|
|
48
|
+
billingLabels?: {
|
|
49
|
+
monthly?: string;
|
|
50
|
+
yearly?: string;
|
|
51
|
+
};
|
|
52
|
+
/** Explicit feature matrix for compare/list/table (auto-built when omitted) */
|
|
45
53
|
comparisonRows?: PricingComparisonRow[];
|
|
46
54
|
/** Append comparison matrix below card layouts */
|
|
47
55
|
showComparison?: boolean;
|
|
@@ -190,7 +190,9 @@
|
|
|
190
190
|
const widthClass = $derived.by(() => {
|
|
191
191
|
const layout = props.layout ?? 'grid';
|
|
192
192
|
if (layout === 'vertical') return 'w-112 max-w-full';
|
|
193
|
-
if (layout === 'table' || props.showComparison)
|
|
193
|
+
if (layout === 'table' || layout === 'list' || layout === 'compare' || props.showComparison) {
|
|
194
|
+
return 'w-208 max-w-full';
|
|
195
|
+
}
|
|
194
196
|
if (layout === 'horizontal') return 'w-192 max-w-full';
|
|
195
197
|
if (layout === 'bento' || layout === 'split') return 'w-208 max-w-full';
|
|
196
198
|
if ((props.columns ?? 3) >= 4 || planSet === 'four') return 'w-224 max-w-full';
|
|
@@ -209,7 +211,12 @@
|
|
|
209
211
|
showFeatures={props.showFeatures ?? true}
|
|
210
212
|
showComparison={props.showComparison ?? false}
|
|
211
213
|
maxFeatures={props.maxFeatures}
|
|
212
|
-
comparisonRows={planSet === 'comparison' ||
|
|
214
|
+
comparisonRows={planSet === 'comparison' ||
|
|
215
|
+
props.layout === 'table' ||
|
|
216
|
+
props.layout === 'list' ||
|
|
217
|
+
props.layout === 'compare'
|
|
218
|
+
? comparisonRows
|
|
219
|
+
: undefined}
|
|
213
220
|
onselect={(id) => (selectedId = id)}
|
|
214
221
|
>
|
|
215
222
|
{#snippet footer()}
|
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
</nav>
|
|
176
176
|
|
|
177
177
|
{#if footer}
|
|
178
|
-
<div class={['border-border min-w-0
|
|
178
|
+
<div class={['border-border min-w-0 border-t p-3', collapsed && 'flex justify-center']}>
|
|
179
179
|
{@render footer()}
|
|
180
180
|
</div>
|
|
181
181
|
{/if}
|