@a4ui/core 0.17.0 → 0.18.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/{Checkbox-DMx0y81E.js → Checkbox-BZjX1GMG.js} +3 -3
- package/dist/commerce/createCart.d.ts +53 -0
- package/dist/commerce/index.d.ts +1 -0
- package/dist/commerce.js +154 -133
- package/dist/elements.css +133 -0
- package/dist/full.css +133 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3002 -2735
- package/dist/layout/ActionBar.d.ts +53 -0
- package/dist/layout/Section.d.ts +31 -0
- package/dist/ui/BeforeAfter.d.ts +34 -0
- package/dist/ui/PricingTable.d.ts +60 -0
- package/package.json +1 -1
- package/src/commerce/ProductCard.tsx +9 -1
- package/src/commerce/createCart.ts +102 -0
- package/src/commerce/index.ts +1 -0
- package/src/index.ts +5 -1
- package/src/layout/ActionBar.tsx +121 -0
- package/src/layout/Section.tsx +69 -0
- package/src/ui/BeforeAfter.tsx +162 -0
- package/src/ui/PricingTable.tsx +196 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** Edge an {@link ActionBar} pins to when `sticky`. Defaults to `'top'`. */
|
|
3
|
+
export type ActionBarPosition = 'top' | 'bottom';
|
|
4
|
+
export interface ActionBarProps {
|
|
5
|
+
/**
|
|
6
|
+
* The prominent CTA. Rendered as `<a href>` when `href` is set (e.g.
|
|
7
|
+
* `tel:+15551234567`), otherwise as a `<button type="button">` driven by
|
|
8
|
+
* `onClick`.
|
|
9
|
+
*/
|
|
10
|
+
action: {
|
|
11
|
+
label: string;
|
|
12
|
+
href?: string;
|
|
13
|
+
onClick?: () => void;
|
|
14
|
+
};
|
|
15
|
+
/** Optional left slot, e.g. a `<Badge pulse>24/7</Badge>`. */
|
|
16
|
+
status?: JSX.Element;
|
|
17
|
+
/** Optional short text between `status` and the action. Hidden below `sm` so the action stays reachable. */
|
|
18
|
+
message?: JSX.Element;
|
|
19
|
+
/** Pins the bar to the viewport edge (`position`) instead of scrolling with the page. Defaults to `true`. */
|
|
20
|
+
sticky?: boolean;
|
|
21
|
+
/** Edge to pin to when `sticky`. Defaults to `'top'`. */
|
|
22
|
+
position?: ActionBarPosition;
|
|
23
|
+
class?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Sticky action / emergency bar: a full-width strip that pins a single
|
|
27
|
+
* prominent CTA to the viewport edge, with an optional status badge and
|
|
28
|
+
* short message beside it. Built for urgent-service verticals (towing, HVAC,
|
|
29
|
+
* locksmith, …) where the CTA — usually a `tel:` link — must stay reachable
|
|
30
|
+
* through the whole scroll. The action gets a click ripple like `<Button
|
|
31
|
+
* ripple>`; the message drops out on narrow viewports so the badge + action
|
|
32
|
+
* never wrap.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```tsx
|
|
36
|
+
* <ActionBar
|
|
37
|
+
* status={<Badge pulse>24/7</Badge>}
|
|
38
|
+
* message="Stuck on the road? We're on our way."
|
|
39
|
+
* action={{ label: 'Call now', href: 'tel:+15551234567' }}
|
|
40
|
+
* />
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* // Bottom-pinned, plain onClick action instead of a tel: link.
|
|
46
|
+
* <ActionBar
|
|
47
|
+
* position="bottom"
|
|
48
|
+
* message="Free quote in 2 minutes"
|
|
49
|
+
* action={{ label: 'Get a quote', onClick: openQuoteForm }}
|
|
50
|
+
* />
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export declare function ActionBar(props: ActionBarProps): JSX.Element;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { JSX, ParentProps } from 'solid-js';
|
|
2
|
+
/** Max content width of a {@link Section}. Defaults to `'6xl'`. */
|
|
3
|
+
export type SectionWidth = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl' | '6xl' | '7xl' | 'full';
|
|
4
|
+
/** Vertical padding of a {@link Section}. Defaults to `'lg'`. */
|
|
5
|
+
export type SectionPad = 'none' | 'sm' | 'md' | 'lg';
|
|
6
|
+
interface SectionProps extends ParentProps, JSX.HTMLAttributes<HTMLElement> {
|
|
7
|
+
/** Max content width. Defaults to `'6xl'`. */
|
|
8
|
+
size?: SectionWidth;
|
|
9
|
+
/** Vertical padding rhythm. Defaults to `'lg'`. */
|
|
10
|
+
py?: SectionPad;
|
|
11
|
+
class?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Centered content container with a max width and consistent vertical rhythm —
|
|
15
|
+
* the layout wrapper repeated on every marketing/company page. Pass an `id` for
|
|
16
|
+
* anchor-based in-page navigation. For a full-bleed colored band, wrap Section
|
|
17
|
+
* in a parent that carries the background.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* <Section id="services">
|
|
22
|
+
* <h2>Servicios</h2>
|
|
23
|
+
* </Section>
|
|
24
|
+
*
|
|
25
|
+
* <div class="bg-muted/30">
|
|
26
|
+
* <Section size="4xl" py="md">…</Section>
|
|
27
|
+
* </div>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function Section(props: SectionProps): JSX.Element;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface BeforeAfterProps {
|
|
3
|
+
/** Image URL shown fully, underneath. */
|
|
4
|
+
before: string;
|
|
5
|
+
/** Image URL revealed by the handle, on top, clipped to the split. */
|
|
6
|
+
after: string;
|
|
7
|
+
/** Accessible description of the comparison, used as the slider's label. */
|
|
8
|
+
alt: string;
|
|
9
|
+
/** Corner labels as `[beforeLabel, afterLabel]`, e.g. `['Antes', 'Después']`. */
|
|
10
|
+
labels?: [string, string];
|
|
11
|
+
/** Initial split position, 0..100 (percent of width revealing `after`). @default 50 */
|
|
12
|
+
start?: number;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A before/after image comparison slider: drag the handle (pointer or
|
|
17
|
+
* Left/Right arrow keys once focused) to reveal more of `after` versus
|
|
18
|
+
* `before`. The `after` image is clipped with `clip-path: inset(...)` driven
|
|
19
|
+
* by a `0..100` split signal — no canvas, no animation engine. Clicking
|
|
20
|
+
* anywhere on the track jumps the split there with a short transition
|
|
21
|
+
* (skipped under `prefers-reduced-motion`); dragging itself is always instant.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* <BeforeAfter
|
|
26
|
+
* before="/room-before.jpg"
|
|
27
|
+
* after="/room-after.jpg"
|
|
28
|
+
* alt="Living room before and after renovation"
|
|
29
|
+
* labels={['Antes', 'Después']}
|
|
30
|
+
* class="aspect-video rounded-2xl border border-border"
|
|
31
|
+
* />
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare function BeforeAfter(props: BeforeAfterProps): JSX.Element;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** Billing period a {@link PricingTable} can show. */
|
|
3
|
+
export type PricingPeriod = 'monthly' | 'annual';
|
|
4
|
+
/** One plan/tier rendered by {@link PricingTable}. */
|
|
5
|
+
export interface PricingTier {
|
|
6
|
+
/** Plan name, e.g. `"Pro"`. */
|
|
7
|
+
name: string;
|
|
8
|
+
/** Pre-formatted price for the monthly period, e.g. `"$0"` or `"$99/mes"`. */
|
|
9
|
+
price: string;
|
|
10
|
+
/** Pre-formatted price for the annual period. When ANY tier sets this, the toggle renders. */
|
|
11
|
+
priceAnnual?: string;
|
|
12
|
+
/** Short one-line description under the plan name. */
|
|
13
|
+
description?: string;
|
|
14
|
+
/** Bullet list of included features, each rendered with a check icon. */
|
|
15
|
+
features: string[];
|
|
16
|
+
/** Renders this tier emphasized: `glass` surface, a ring, and a "Popular" {@link Badge}. */
|
|
17
|
+
highlighted?: boolean;
|
|
18
|
+
/** Call-to-action button. Rendered as an `<a>` when `href` is set, otherwise a `<button>`. */
|
|
19
|
+
cta?: {
|
|
20
|
+
label: string;
|
|
21
|
+
href?: string;
|
|
22
|
+
onClick?: () => void;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
interface PricingTableProps {
|
|
26
|
+
/** Tiers to render, in order, as a responsive grid. */
|
|
27
|
+
tiers: PricingTier[];
|
|
28
|
+
/** Controlled billing period. Omit to let the toggle manage its own state. */
|
|
29
|
+
period?: PricingPeriod;
|
|
30
|
+
/** Fired when the toggle changes period, controlled or uncontrolled. */
|
|
31
|
+
onPeriodChange?: (period: PricingPeriod) => void;
|
|
32
|
+
class?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Responsive pricing grid: one {@link Card} per tier, a feature checklist, and
|
|
36
|
+
* a CTA {@link Button}. The highlighted tier gets a frosted glass surface, a
|
|
37
|
+
* ring, and a "Popular" {@link Badge}. When any tier sets `priceAnnual`, a
|
|
38
|
+
* monthly/annual toggle renders above the grid — pass `period` +
|
|
39
|
+
* `onPeriodChange` to control it, or omit both to let it manage its own state.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```tsx
|
|
43
|
+
* <PricingTable
|
|
44
|
+
* tiers={[
|
|
45
|
+
* { name: 'Free', price: '$0', features: ['1 project', 'Community support'], cta: { label: 'Start' } },
|
|
46
|
+
* {
|
|
47
|
+
* name: 'Pro',
|
|
48
|
+
* price: '$19/mo',
|
|
49
|
+
* priceAnnual: '$190/yr',
|
|
50
|
+
* description: 'For growing teams',
|
|
51
|
+
* features: ['Unlimited projects', 'Priority support'],
|
|
52
|
+
* highlighted: true,
|
|
53
|
+
* cta: { label: 'Upgrade', href: '/upgrade' },
|
|
54
|
+
* },
|
|
55
|
+
* ]}
|
|
56
|
+
* />
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export declare function PricingTable(props: PricingTableProps): JSX.Element;
|
|
60
|
+
export {};
|
package/package.json
CHANGED
|
@@ -127,7 +127,15 @@ export function ProductCard(props: ProductCardProps): JSX.Element {
|
|
|
127
127
|
locale={props.locale}
|
|
128
128
|
/>
|
|
129
129
|
<Show when={props.onAddToCart}>
|
|
130
|
-
|
|
130
|
+
{/* stopPropagation so a card wrapped in a link/router-<A> adds to cart
|
|
131
|
+
without also navigating. */}
|
|
132
|
+
<Button
|
|
133
|
+
class="mt-auto w-full"
|
|
134
|
+
onClick={(e) => {
|
|
135
|
+
e.stopPropagation()
|
|
136
|
+
props.onAddToCart?.()
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
131
139
|
Add to cart
|
|
132
140
|
</Button>
|
|
133
141
|
</Show>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// A minimal signal-backed shopping cart store: add/remove/setQty/clear plus
|
|
2
|
+
// derived count() and subtotal(). Generic over the product shape so it can
|
|
3
|
+
// back CartLine/CartSummary (or any custom card) without re-implementing the
|
|
4
|
+
// same bookkeeping in every commerce template.
|
|
5
|
+
import type { Accessor } from 'solid-js'
|
|
6
|
+
import { createMemo, createSignal } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
export interface CartProduct {
|
|
9
|
+
id: string | number
|
|
10
|
+
price: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CartEntry<T extends CartProduct> {
|
|
14
|
+
product: T
|
|
15
|
+
qty: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Cart<T extends CartProduct> {
|
|
19
|
+
/** Reactive list of cart entries. */
|
|
20
|
+
items: Accessor<CartEntry<T>[]>
|
|
21
|
+
/** Add a product, or increment its `qty` if it's already in the cart. */
|
|
22
|
+
add: (product: T, qty?: number) => void
|
|
23
|
+
/** Remove a product from the cart by id. */
|
|
24
|
+
remove: (id: T['id']) => void
|
|
25
|
+
/** Set a product's quantity. `qty <= 0` removes the entry. */
|
|
26
|
+
setQty: (id: T['id'], qty: number) => void
|
|
27
|
+
/** Empty the cart. */
|
|
28
|
+
clear: () => void
|
|
29
|
+
/** Total number of units across all entries. */
|
|
30
|
+
count: () => number
|
|
31
|
+
/** Sum of `price * qty` across all entries. */
|
|
32
|
+
subtotal: () => number
|
|
33
|
+
/** Whether a product with this id is currently in the cart. */
|
|
34
|
+
has: (id: T['id']) => boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create a signal-backed shopping cart store, generic over the product type.
|
|
39
|
+
* `count()` and `subtotal()` are memoized and stay in sync with `items()`.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* interface Product extends CartProduct {
|
|
44
|
+
* id: string
|
|
45
|
+
* price: number
|
|
46
|
+
* title: string
|
|
47
|
+
* }
|
|
48
|
+
*
|
|
49
|
+
* const cart = createCart<Product>()
|
|
50
|
+
*
|
|
51
|
+
* cart.add({ id: 'sku-1', price: 29.99, title: 'Wireless Mouse' })
|
|
52
|
+
* cart.add({ id: 'sku-1', price: 29.99, title: 'Wireless Mouse' }) // qty -> 2
|
|
53
|
+
* cart.setQty('sku-1', 3)
|
|
54
|
+
*
|
|
55
|
+
* cart.count() // 3
|
|
56
|
+
* cart.subtotal() // 89.97
|
|
57
|
+
*
|
|
58
|
+
* cart.remove('sku-1')
|
|
59
|
+
* cart.has('sku-1') // false
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function createCart<T extends CartProduct>(initial: CartEntry<T>[] = []): Cart<T> {
|
|
63
|
+
const [items, setItems] = createSignal<CartEntry<T>[]>(initial)
|
|
64
|
+
|
|
65
|
+
const add = (product: T, qty = 1): void => {
|
|
66
|
+
setItems((current) => {
|
|
67
|
+
const existing = current.find((entry) => entry.product.id === product.id)
|
|
68
|
+
if (existing) {
|
|
69
|
+
return current.map((entry) =>
|
|
70
|
+
entry.product.id === product.id ? { ...entry, qty: entry.qty + qty } : entry,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
return [...current, { product, qty }]
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const remove = (id: T['id']): void => {
|
|
78
|
+
setItems((current) => current.filter((entry) => entry.product.id !== id))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const setQty = (id: T['id'], qty: number): void => {
|
|
82
|
+
if (qty <= 0) {
|
|
83
|
+
remove(id)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
setItems((current) => current.map((entry) => (entry.product.id === id ? { ...entry, qty } : entry)))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const clear = (): void => {
|
|
90
|
+
setItems([])
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const count = createMemo(() => items().reduce((total, entry) => total + entry.qty, 0))
|
|
94
|
+
|
|
95
|
+
const subtotal = createMemo(() =>
|
|
96
|
+
items().reduce((total, entry) => total + entry.product.price * entry.qty, 0),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const has = (id: T['id']): boolean => items().some((entry) => entry.product.id === id)
|
|
100
|
+
|
|
101
|
+
return { items, add, remove, setQty, clear, count, subtotal, has }
|
|
102
|
+
}
|
package/src/commerce/index.ts
CHANGED
|
@@ -8,3 +8,4 @@ export { ProductGrid, type ProductGridProps } from './ProductGrid'
|
|
|
8
8
|
export { CartLine, type CartLineProps } from './CartLine'
|
|
9
9
|
export { CartSummary, type CartSummaryProps, type CartSummaryItem } from './CartSummary'
|
|
10
10
|
export { FilterGroup, type FilterGroupProps, type FacetOption } from './FilterGroup'
|
|
11
|
+
export { createCart, type Cart, type CartProduct, type CartEntry } from './createCart'
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// import '@a4ui/core/styles.css'
|
|
9
9
|
// import { Button, Card, Modal } from '@a4ui/core'
|
|
10
10
|
|
|
11
|
-
export const A4UI_VERSION = '0.
|
|
11
|
+
export const A4UI_VERSION = '0.18.0'
|
|
12
12
|
|
|
13
13
|
// Helpers (src/lib) — generic, framework-level utilities.
|
|
14
14
|
export { cn } from './lib/cn'
|
|
@@ -86,6 +86,8 @@ export { Carousel, type CarouselProps } from './ui/Carousel'
|
|
|
86
86
|
export { Stepper, type StepItem, type StepperProps } from './ui/Stepper'
|
|
87
87
|
export { Timeline, type TimelineItem, type TimelineTone, type TimelineProps } from './ui/Timeline'
|
|
88
88
|
export { Rating, type RatingProps } from './ui/Rating'
|
|
89
|
+
export { PricingTable, type PricingTier, type PricingPeriod } from './ui/PricingTable'
|
|
90
|
+
export { BeforeAfter, type BeforeAfterProps } from './ui/BeforeAfter'
|
|
89
91
|
export { Empty, type EmptyProps } from './ui/Empty'
|
|
90
92
|
export { Calendar, type CalendarProps } from './ui/Calendar'
|
|
91
93
|
export { Tree, type TreeNode, type TreeProps } from './ui/Tree'
|
|
@@ -168,6 +170,8 @@ export { ChristmasBackground } from './layout/ChristmasBackground'
|
|
|
168
170
|
export { ThemeToggle } from './layout/ThemeToggle'
|
|
169
171
|
export { EffectsToggle } from './layout/EffectsToggle'
|
|
170
172
|
export { NavGroup } from './layout/NavGroup'
|
|
173
|
+
export { Section, type SectionWidth, type SectionPad } from './layout/Section'
|
|
174
|
+
export { ActionBar, type ActionBarProps, type ActionBarPosition } from './layout/ActionBar'
|
|
171
175
|
|
|
172
176
|
// Themes (src/themes) — swappable color palettes (Space is the default). Pick one
|
|
173
177
|
// with `selectTheme('dino')`, restore the saved one with `initTheme()`, or build
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// ActionBar — sticky action / emergency bar: a full-width strip that pins one
|
|
2
|
+
// prominent CTA to the viewport edge, with an optional status badge + short
|
|
3
|
+
// message beside it. Built for urgent-service verticals (towing, HVAC,
|
|
4
|
+
// locksmith, …) where the call-to-action — usually a `tel:` link — has to
|
|
5
|
+
// stay reachable through the whole page scroll. On narrow viewports the
|
|
6
|
+
// message drops out first so the badge + action never wrap or get pushed
|
|
7
|
+
// off-screen.
|
|
8
|
+
import { Show, splitProps, type JSX } from 'solid-js'
|
|
9
|
+
|
|
10
|
+
import { cn } from '../lib/cn'
|
|
11
|
+
import { spawnRipple } from '../ui/Ripple'
|
|
12
|
+
|
|
13
|
+
/** Edge an {@link ActionBar} pins to when `sticky`. Defaults to `'top'`. */
|
|
14
|
+
export type ActionBarPosition = 'top' | 'bottom'
|
|
15
|
+
|
|
16
|
+
export interface ActionBarProps {
|
|
17
|
+
/**
|
|
18
|
+
* The prominent CTA. Rendered as `<a href>` when `href` is set (e.g.
|
|
19
|
+
* `tel:+15551234567`), otherwise as a `<button type="button">` driven by
|
|
20
|
+
* `onClick`.
|
|
21
|
+
*/
|
|
22
|
+
action: { label: string; href?: string; onClick?: () => void }
|
|
23
|
+
/** Optional left slot, e.g. a `<Badge pulse>24/7</Badge>`. */
|
|
24
|
+
status?: JSX.Element
|
|
25
|
+
/** Optional short text between `status` and the action. Hidden below `sm` so the action stays reachable. */
|
|
26
|
+
message?: JSX.Element
|
|
27
|
+
/** Pins the bar to the viewport edge (`position`) instead of scrolling with the page. Defaults to `true`. */
|
|
28
|
+
sticky?: boolean
|
|
29
|
+
/** Edge to pin to when `sticky`. Defaults to `'top'`. */
|
|
30
|
+
position?: ActionBarPosition
|
|
31
|
+
class?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const POSITION_CLASSES: Record<ActionBarPosition, string> = {
|
|
35
|
+
top: 'top-0',
|
|
36
|
+
bottom: 'bottom-0',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Deliberately not the Button primitive: Button always renders a <button>,
|
|
40
|
+
// and this CTA needs to be a real <a href="tel:…"> for click-to-call. The
|
|
41
|
+
// classes mirror Button's shape/motion but use accent (not primary) so the
|
|
42
|
+
// CTA still pops against the bar's own bg-primary surface.
|
|
43
|
+
const ACTION_CLASSES =
|
|
44
|
+
'relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-md bg-accent px-4 py-2 text-sm font-semibold whitespace-nowrap text-accent-foreground transition-[background-color,transform] duration-150 hover:bg-accent/90 active:scale-[0.97] focus:outline-none focus:ring-2 focus:ring-ring'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sticky action / emergency bar: a full-width strip that pins a single
|
|
48
|
+
* prominent CTA to the viewport edge, with an optional status badge and
|
|
49
|
+
* short message beside it. Built for urgent-service verticals (towing, HVAC,
|
|
50
|
+
* locksmith, …) where the CTA — usually a `tel:` link — must stay reachable
|
|
51
|
+
* through the whole scroll. The action gets a click ripple like `<Button
|
|
52
|
+
* ripple>`; the message drops out on narrow viewports so the badge + action
|
|
53
|
+
* never wrap.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* <ActionBar
|
|
58
|
+
* status={<Badge pulse>24/7</Badge>}
|
|
59
|
+
* message="Stuck on the road? We're on our way."
|
|
60
|
+
* action={{ label: 'Call now', href: 'tel:+15551234567' }}
|
|
61
|
+
* />
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```tsx
|
|
66
|
+
* // Bottom-pinned, plain onClick action instead of a tel: link.
|
|
67
|
+
* <ActionBar
|
|
68
|
+
* position="bottom"
|
|
69
|
+
* message="Free quote in 2 minutes"
|
|
70
|
+
* action={{ label: 'Get a quote', onClick: openQuoteForm }}
|
|
71
|
+
* />
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function ActionBar(props: ActionBarProps): JSX.Element {
|
|
75
|
+
const [local] = splitProps(props, ['action', 'status', 'message', 'sticky', 'position', 'class'])
|
|
76
|
+
|
|
77
|
+
const handlePointerDown: JSX.EventHandler<HTMLElement, PointerEvent> = (event) => {
|
|
78
|
+
spawnRipple(event.currentTarget, event, { opacity: 0.35 })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
class={cn(
|
|
84
|
+
'z-40 flex w-full items-center justify-between gap-3 bg-primary px-4 py-3 text-primary-foreground',
|
|
85
|
+
(local.sticky ?? true) && cn('sticky', POSITION_CLASSES[local.position ?? 'top']),
|
|
86
|
+
local.class,
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
<div class="flex min-w-0 items-center gap-2 sm:gap-3">
|
|
90
|
+
<Show when={local.status}>{local.status}</Show>
|
|
91
|
+
<Show when={local.message}>
|
|
92
|
+
<p class="hidden min-w-0 truncate text-sm sm:block sm:text-base">{local.message}</p>
|
|
93
|
+
</Show>
|
|
94
|
+
</div>
|
|
95
|
+
<Show
|
|
96
|
+
when={local.action.href}
|
|
97
|
+
fallback={
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
class={ACTION_CLASSES}
|
|
101
|
+
onPointerDown={handlePointerDown}
|
|
102
|
+
onClick={local.action.onClick}
|
|
103
|
+
>
|
|
104
|
+
{local.action.label}
|
|
105
|
+
</button>
|
|
106
|
+
}
|
|
107
|
+
>
|
|
108
|
+
{(href) => (
|
|
109
|
+
<a
|
|
110
|
+
href={href()}
|
|
111
|
+
class={ACTION_CLASSES}
|
|
112
|
+
onPointerDown={handlePointerDown}
|
|
113
|
+
onClick={local.action.onClick}
|
|
114
|
+
>
|
|
115
|
+
{local.action.label}
|
|
116
|
+
</a>
|
|
117
|
+
)}
|
|
118
|
+
</Show>
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Section — a centered content container with a max width and vertical rhythm.
|
|
2
|
+
// The layout wrapper every page repeats: `mx-auto` + a max-width + horizontal
|
|
3
|
+
// padding + section padding, with an optional `id` anchor for in-page nav. No
|
|
4
|
+
// visuals of its own — put a full-bleed background on a parent and let Section
|
|
5
|
+
// hold the centered content.
|
|
6
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
7
|
+
import { splitProps } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
|
|
11
|
+
/** Max content width of a {@link Section}. Defaults to `'6xl'`. */
|
|
12
|
+
export type SectionWidth = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl' | '6xl' | '7xl' | 'full'
|
|
13
|
+
/** Vertical padding of a {@link Section}. Defaults to `'lg'`. */
|
|
14
|
+
export type SectionPad = 'none' | 'sm' | 'md' | 'lg'
|
|
15
|
+
|
|
16
|
+
const WIDTH: Record<SectionWidth, string> = {
|
|
17
|
+
sm: 'max-w-sm',
|
|
18
|
+
md: 'max-w-md',
|
|
19
|
+
lg: 'max-w-lg',
|
|
20
|
+
xl: 'max-w-xl',
|
|
21
|
+
'2xl': 'max-w-2xl',
|
|
22
|
+
'4xl': 'max-w-4xl',
|
|
23
|
+
'6xl': 'max-w-6xl',
|
|
24
|
+
'7xl': 'max-w-7xl',
|
|
25
|
+
full: 'max-w-full',
|
|
26
|
+
}
|
|
27
|
+
const PAD: Record<SectionPad, string> = {
|
|
28
|
+
none: 'py-0',
|
|
29
|
+
sm: 'py-8',
|
|
30
|
+
md: 'py-14',
|
|
31
|
+
lg: 'py-20',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SectionProps extends ParentProps, JSX.HTMLAttributes<HTMLElement> {
|
|
35
|
+
/** Max content width. Defaults to `'6xl'`. */
|
|
36
|
+
size?: SectionWidth
|
|
37
|
+
/** Vertical padding rhythm. Defaults to `'lg'`. */
|
|
38
|
+
py?: SectionPad
|
|
39
|
+
class?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Centered content container with a max width and consistent vertical rhythm —
|
|
44
|
+
* the layout wrapper repeated on every marketing/company page. Pass an `id` for
|
|
45
|
+
* anchor-based in-page navigation. For a full-bleed colored band, wrap Section
|
|
46
|
+
* in a parent that carries the background.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* <Section id="services">
|
|
51
|
+
* <h2>Servicios</h2>
|
|
52
|
+
* </Section>
|
|
53
|
+
*
|
|
54
|
+
* <div class="bg-muted/30">
|
|
55
|
+
* <Section size="4xl" py="md">…</Section>
|
|
56
|
+
* </div>
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function Section(props: SectionProps): JSX.Element {
|
|
60
|
+
const [local, rest] = splitProps(props, ['size', 'py', 'class', 'children'])
|
|
61
|
+
return (
|
|
62
|
+
<section
|
|
63
|
+
class={cn('mx-auto w-full px-5', WIDTH[local.size ?? '6xl'], PAD[local.py ?? 'lg'], local.class)}
|
|
64
|
+
{...rest}
|
|
65
|
+
>
|
|
66
|
+
{local.children}
|
|
67
|
+
</section>
|
|
68
|
+
)
|
|
69
|
+
}
|