@pya-platform/ui 0.1.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/CHANGELOG.md +14 -0
- package/package.json +29 -0
- package/src/components/pya-button/index.ts +2 -0
- package/src/components/pya-button/pya-button.css +76 -0
- package/src/components/pya-button/pya-button.locators.ts +4 -0
- package/src/components/pya-button/pya-button.ts +58 -0
- package/src/components/pya-cart-badge/index.ts +1 -0
- package/src/components/pya-cart-badge/pya-cart-badge.css +12 -0
- package/src/components/pya-cart-badge/pya-cart-badge.ts +39 -0
- package/src/components/pya-filter-chip/index.ts +1 -0
- package/src/components/pya-filter-chip/pya-filter-chip.css +35 -0
- package/src/components/pya-filter-chip/pya-filter-chip.ts +43 -0
- package/src/components/pya-quantity-stepper/index.ts +1 -0
- package/src/components/pya-quantity-stepper/pya-quantity-stepper.css +31 -0
- package/src/components/pya-quantity-stepper/pya-quantity-stepper.ts +63 -0
- package/src/components/pya-store-card/index.ts +2 -0
- package/src/components/pya-store-card/pya-store-card.css +126 -0
- package/src/components/pya-store-card/pya-store-card.locators.ts +6 -0
- package/src/components/pya-store-card/pya-store-card.ts +61 -0
- package/src/controllers/announcer.ts +29 -0
- package/src/controllers/cart-store.ts +101 -0
- package/src/helpers/format-money.ts +17 -0
- package/src/index.ts +9 -0
- package/tsconfig.json +11 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# @pya/ui
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a9ca6bf: Initial release of the Pya platform packages. Extracted from `pyaeats-app`, consumed by `pyaeats-app` (food delivery) and `pyaserv` (services classifieds).
|
|
8
|
+
|
|
9
|
+
Each package exposes a Hono router factory (auth/cms/reviews/comments) or a typed helper (email/audit/cf) parameterised over Cloudflare D1 + KV bindings. UI primitives ship as Lit web components on top of `@pya/tokens` (CSS custom properties). See `ROADMAP.md` and `docs/phase-6-rollout.md` for the consumer cutover plan.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Updated dependencies [a9ca6bf]
|
|
14
|
+
- @pya/tokens@0.1.0
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pya-platform/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"registry": "https://registry.npmjs.org",
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/undeadliner/pya-platform.git"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"description": "Lit web components shared across Pya projects — buttons, badges, cards, filter chips, quantity steppers, plus a11y/state controllers.",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.ts",
|
|
17
|
+
"./components/*": "./src/components/*",
|
|
18
|
+
"./controllers/*": "./src/controllers/*",
|
|
19
|
+
"./helpers/*": "./src/helpers/*"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"type-check": "tsc --noEmit",
|
|
23
|
+
"test": "echo '@pya/ui has no tests yet'"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@pya-platform/tokens": "workspace:*",
|
|
27
|
+
"lit": "^3.2.1"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/* Light-DOM styles for <pya-button>. Imported by sites consuming the component
|
|
2
|
+
(apps/site, apps/admin) via `import '@pyaeats/ui/components/pya-button/pya-button.css'`. */
|
|
3
|
+
|
|
4
|
+
.pya-button {
|
|
5
|
+
display: inline-flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
justify-content: center;
|
|
8
|
+
gap: var(--pya-space-2);
|
|
9
|
+
min-height: var(--pya-target-min);
|
|
10
|
+
padding-inline: var(--pya-space-4);
|
|
11
|
+
padding-block: var(--pya-space-2);
|
|
12
|
+
border: 2px solid transparent;
|
|
13
|
+
border-radius: var(--pya-radius-pill);
|
|
14
|
+
font: inherit;
|
|
15
|
+
font-weight: 700;
|
|
16
|
+
font-size: var(--pya-font-size-md);
|
|
17
|
+
text-align: center;
|
|
18
|
+
cursor: pointer;
|
|
19
|
+
transition: background-color var(--pya-motion-fast) var(--pya-ease-standard), transform
|
|
20
|
+
var(--pya-motion-fast) var(--pya-ease-standard);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* Focus ring inherited from tokens/base.css via :where(button):focus-visible.
|
|
24
|
+
No override here — keep the soft halo consistent across the design system. */
|
|
25
|
+
|
|
26
|
+
.pya-button[disabled] {
|
|
27
|
+
cursor: not-allowed;
|
|
28
|
+
opacity: 0.55;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.pya-button--primary {
|
|
32
|
+
background: linear-gradient(135deg, var(--pya-acc), var(--pya-acc2));
|
|
33
|
+
color: var(--pya-text-on-acc);
|
|
34
|
+
box-shadow: 0 8px 20px color-mix(in srgb, var(--pya-acc2) 30%, transparent);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.pya-button--primary:hover:not([disabled]) {
|
|
38
|
+
transform: translateY(-1px);
|
|
39
|
+
}
|
|
40
|
+
.pya-button--primary:active:not([disabled]) {
|
|
41
|
+
transform: translateY(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.pya-button--ghost {
|
|
45
|
+
background: var(--pya-surface-2);
|
|
46
|
+
color: var(--pya-text);
|
|
47
|
+
border-color: var(--pya-border);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.pya-button--danger {
|
|
51
|
+
background: #c0392b;
|
|
52
|
+
color: #fff;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.pya-button--sm {
|
|
56
|
+
min-height: var(--pya-target-min);
|
|
57
|
+
padding-inline: var(--pya-space-3);
|
|
58
|
+
font-size: var(--pya-font-size-sm);
|
|
59
|
+
}
|
|
60
|
+
.pya-button--lg {
|
|
61
|
+
min-height: var(--pya-target-comfortable);
|
|
62
|
+
padding-inline: var(--pya-space-5);
|
|
63
|
+
font-size: var(--pya-font-size-lg);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@media (prefers-contrast: more) {
|
|
67
|
+
.pya-button {
|
|
68
|
+
border-width: 3px;
|
|
69
|
+
border-color: ButtonText;
|
|
70
|
+
}
|
|
71
|
+
.pya-button--primary {
|
|
72
|
+
background: Highlight;
|
|
73
|
+
color: HighlightText;
|
|
74
|
+
box-shadow: none;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { LitElement, css, html } from 'lit'
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
3
|
+
import { LOCATORS } from './pya-button.locators.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Accessible button primitive. Light DOM (a11y tree integrity, global token
|
|
7
|
+
* cascade). AAA: target ≥44px, 3px focus ring with 7:1 contrast, motion via
|
|
8
|
+
* --pya-motion-* tokens (auto-collapses under prefers-reduced-motion).
|
|
9
|
+
*/
|
|
10
|
+
@customElement('pya-button')
|
|
11
|
+
export class PyaButton extends LitElement {
|
|
12
|
+
protected override createRenderRoot(): HTMLElement {
|
|
13
|
+
return this
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@property({ type: String, reflect: true }) readonly variant: 'primary' | 'ghost' | 'danger' =
|
|
17
|
+
'primary'
|
|
18
|
+
|
|
19
|
+
@property({ type: String, reflect: true }) readonly size: 'sm' | 'md' | 'lg' = 'md'
|
|
20
|
+
|
|
21
|
+
@property({ type: Boolean, reflect: true }) readonly disabled = false
|
|
22
|
+
|
|
23
|
+
@property({ type: String, reflect: true }) readonly type: 'button' | 'submit' | 'reset' = 'button'
|
|
24
|
+
|
|
25
|
+
@property({ type: String }) readonly label = ''
|
|
26
|
+
|
|
27
|
+
static override styles = css`
|
|
28
|
+
/* light DOM — styles emitted via adoptedStyleSheets are still scoped. */
|
|
29
|
+
`
|
|
30
|
+
|
|
31
|
+
protected override render() {
|
|
32
|
+
return html`
|
|
33
|
+
<button
|
|
34
|
+
type=${this.type}
|
|
35
|
+
class=${`pya-button pya-button--${this.variant} pya-button--${this.size}`}
|
|
36
|
+
?disabled=${this.disabled}
|
|
37
|
+
data-testid=${LOCATORS.root}
|
|
38
|
+
aria-label=${this.label || ''}
|
|
39
|
+
@click=${this.handleClick}
|
|
40
|
+
>
|
|
41
|
+
<slot></slot>
|
|
42
|
+
</button>
|
|
43
|
+
`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private readonly handleClick = (event: MouseEvent): void => {
|
|
47
|
+
if (this.disabled) {
|
|
48
|
+
event.preventDefault()
|
|
49
|
+
event.stopPropagation()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
declare global {
|
|
55
|
+
interface HTMLElementTagNameMap {
|
|
56
|
+
'pya-button': PyaButton
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PyaCartBadge } from './pya-cart-badge.ts'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
.pya-cart-badge {
|
|
2
|
+
display: inline-grid;
|
|
3
|
+
place-items: center;
|
|
4
|
+
min-width: 20px;
|
|
5
|
+
height: 20px;
|
|
6
|
+
padding: 0 6px;
|
|
7
|
+
border-radius: var(--pya-radius-pill);
|
|
8
|
+
background: linear-gradient(135deg, var(--pya-acc), var(--pya-acc2));
|
|
9
|
+
color: var(--pya-text-on-acc);
|
|
10
|
+
font-size: 11px;
|
|
11
|
+
font-weight: 800;
|
|
12
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { LitElement, html, nothing } from 'lit'
|
|
2
|
+
import { customElement, state } from 'lit/decorators.js'
|
|
3
|
+
import { cartStore } from '../../controllers/cart-store.ts'
|
|
4
|
+
|
|
5
|
+
/** Live cart badge — subscribes to cartStore and announces changes. */
|
|
6
|
+
@customElement('pya-cart-badge')
|
|
7
|
+
export class PyaCartBadge extends LitElement {
|
|
8
|
+
protected override createRenderRoot(): HTMLElement {
|
|
9
|
+
return this
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@state() private count = 0
|
|
13
|
+
|
|
14
|
+
private unsub?: () => void
|
|
15
|
+
|
|
16
|
+
override connectedCallback(): void {
|
|
17
|
+
super.connectedCallback()
|
|
18
|
+
this.count = cartStore.count()
|
|
19
|
+
this.unsub = cartStore.subscribe((state) => {
|
|
20
|
+
this.count = state.lines.reduce((s, l) => s + l.qty, 0)
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
override disconnectedCallback(): void {
|
|
25
|
+
super.disconnectedCallback()
|
|
26
|
+
this.unsub?.()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
protected override render() {
|
|
30
|
+
if (this.count === 0) return nothing
|
|
31
|
+
return html`<span class="pya-cart-badge" aria-label=${`${this.count} artículo${this.count === 1 ? '' : 's'} en el carrito`}>${this.count}</span>`
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
declare global {
|
|
36
|
+
interface HTMLElementTagNameMap {
|
|
37
|
+
'pya-cart-badge': PyaCartBadge
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PyaFilterChip } from './pya-filter-chip.ts'
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.pya-chip {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--pya-space-2);
|
|
5
|
+
min-height: var(--pya-target-min);
|
|
6
|
+
padding: 8px 14px;
|
|
7
|
+
border: 1px solid var(--pya-border);
|
|
8
|
+
border-radius: var(--pya-radius-pill);
|
|
9
|
+
background: var(--pya-surface);
|
|
10
|
+
color: var(--pya-text);
|
|
11
|
+
font: inherit;
|
|
12
|
+
font-weight: 600;
|
|
13
|
+
font-size: var(--pya-font-size-sm);
|
|
14
|
+
cursor: pointer;
|
|
15
|
+
transition: background-color var(--pya-motion-fast) var(--pya-ease-standard);
|
|
16
|
+
}
|
|
17
|
+
/* Focus ring inherited from tokens/base.css. */
|
|
18
|
+
.pya-chip[aria-pressed="true"] {
|
|
19
|
+
background: var(--pya-acc);
|
|
20
|
+
color: var(--pya-text-on-acc);
|
|
21
|
+
border-color: var(--pya-acc);
|
|
22
|
+
}
|
|
23
|
+
@media (prefers-contrast: more) {
|
|
24
|
+
.pya-chip {
|
|
25
|
+
border-width: 3px;
|
|
26
|
+
border-color: ButtonText;
|
|
27
|
+
background: ButtonFace;
|
|
28
|
+
color: ButtonText;
|
|
29
|
+
}
|
|
30
|
+
.pya-chip[aria-pressed="true"] {
|
|
31
|
+
background: Highlight;
|
|
32
|
+
color: HighlightText;
|
|
33
|
+
border-color: Highlight;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { LitElement, html } from 'lit'
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
3
|
+
|
|
4
|
+
/** Multi-select filter chip. `<button aria-pressed>` pattern (per ARIA APG). */
|
|
5
|
+
@customElement('pya-filter-chip')
|
|
6
|
+
export class PyaFilterChip extends LitElement {
|
|
7
|
+
protected override createRenderRoot(): HTMLElement {
|
|
8
|
+
return this
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@property({ type: Boolean, reflect: true }) pressed = false
|
|
12
|
+
@property({ type: String }) readonly label = ''
|
|
13
|
+
@property({ type: String }) readonly icon = ''
|
|
14
|
+
|
|
15
|
+
protected override render() {
|
|
16
|
+
return html`
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
class="pya-chip"
|
|
20
|
+
aria-pressed=${String(this.pressed)}
|
|
21
|
+
@click=${this.toggle}>
|
|
22
|
+
${this.icon !== '' ? html`<span aria-hidden="true">${this.icon}</span>` : ''} ${this.label}
|
|
23
|
+
</button>
|
|
24
|
+
`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private readonly toggle = (): void => {
|
|
28
|
+
this.pressed = !this.pressed
|
|
29
|
+
this.dispatchEvent(
|
|
30
|
+
new CustomEvent('chip-toggle', {
|
|
31
|
+
detail: { pressed: this.pressed, label: this.label },
|
|
32
|
+
bubbles: true,
|
|
33
|
+
composed: true,
|
|
34
|
+
}),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
declare global {
|
|
40
|
+
interface HTMLElementTagNameMap {
|
|
41
|
+
'pya-filter-chip': PyaFilterChip
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PyaQuantityStepper } from './pya-quantity-stepper.ts'
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
.pya-stepper {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--pya-space-2);
|
|
5
|
+
}
|
|
6
|
+
.pya-stepper__btn {
|
|
7
|
+
width: var(--pya-target-min);
|
|
8
|
+
height: var(--pya-target-min);
|
|
9
|
+
border-radius: var(--pya-radius-pill);
|
|
10
|
+
background: var(--pya-surface-2);
|
|
11
|
+
border: 1px solid var(--pya-border);
|
|
12
|
+
color: var(--pya-text);
|
|
13
|
+
font-size: var(--pya-font-size-lg);
|
|
14
|
+
font-weight: 700;
|
|
15
|
+
cursor: pointer;
|
|
16
|
+
}
|
|
17
|
+
/* Focus ring inherited from tokens/base.css. */
|
|
18
|
+
.pya-stepper__btn:disabled {
|
|
19
|
+
opacity: 0.4;
|
|
20
|
+
cursor: not-allowed;
|
|
21
|
+
}
|
|
22
|
+
.pya-stepper__btn:active {
|
|
23
|
+
transform: scale(0.92);
|
|
24
|
+
}
|
|
25
|
+
.pya-stepper__value {
|
|
26
|
+
min-width: 24px;
|
|
27
|
+
text-align: center;
|
|
28
|
+
font-weight: 700;
|
|
29
|
+
font-size: var(--pya-font-size-md);
|
|
30
|
+
font-variant-numeric: tabular-nums;
|
|
31
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { LitElement, html } from 'lit'
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
3
|
+
import { announcer } from '../../controllers/announcer.ts'
|
|
4
|
+
|
|
5
|
+
/** Accessible quantity stepper. Form-associated via ElementInternals. */
|
|
6
|
+
@customElement('pya-quantity-stepper')
|
|
7
|
+
export class PyaQuantityStepper extends LitElement {
|
|
8
|
+
static formAssociated = true
|
|
9
|
+
private readonly internals = this.attachInternals()
|
|
10
|
+
|
|
11
|
+
protected override createRenderRoot(): HTMLElement {
|
|
12
|
+
return this
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@property({ type: Number }) value = 1
|
|
16
|
+
@property({ type: Number, attribute: 'min' }) readonly min = 1
|
|
17
|
+
@property({ type: Number, attribute: 'max' }) readonly max = 99
|
|
18
|
+
@property({ type: String }) readonly label = 'Cantidad'
|
|
19
|
+
|
|
20
|
+
override connectedCallback(): void {
|
|
21
|
+
super.connectedCallback()
|
|
22
|
+
this.internals.setFormValue(String(this.value))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
protected override render() {
|
|
26
|
+
return html`
|
|
27
|
+
<div class="pya-stepper" role="group" aria-labelledby=${`stepper-label-${this.label}`}>
|
|
28
|
+
<span id=${`stepper-label-${this.label}`} class="visually-hidden">${this.label}</span>
|
|
29
|
+
<button type="button" class="pya-stepper__btn"
|
|
30
|
+
aria-label="Quitar uno"
|
|
31
|
+
?disabled=${this.value <= this.min}
|
|
32
|
+
@click=${this.dec}>−</button>
|
|
33
|
+
<output class="pya-stepper__value" aria-live="polite">${this.value}</output>
|
|
34
|
+
<button type="button" class="pya-stepper__btn"
|
|
35
|
+
aria-label="Agregar uno"
|
|
36
|
+
?disabled=${this.value >= this.max}
|
|
37
|
+
@click=${this.inc}>+</button>
|
|
38
|
+
</div>
|
|
39
|
+
`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private readonly inc = (): void => {
|
|
43
|
+
if (this.value >= this.max) return
|
|
44
|
+
this.value = this.value + 1
|
|
45
|
+
this.internals.setFormValue(String(this.value))
|
|
46
|
+
this.dispatchEvent(new CustomEvent('change', { detail: { value: this.value }, bubbles: true }))
|
|
47
|
+
announcer.announce(`Cantidad: ${this.value}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private readonly dec = (): void => {
|
|
51
|
+
if (this.value <= this.min) return
|
|
52
|
+
this.value = this.value - 1
|
|
53
|
+
this.internals.setFormValue(String(this.value))
|
|
54
|
+
this.dispatchEvent(new CustomEvent('change', { detail: { value: this.value }, bubbles: true }))
|
|
55
|
+
announcer.announce(`Cantidad: ${this.value}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
declare global {
|
|
60
|
+
interface HTMLElementTagNameMap {
|
|
61
|
+
'pya-quantity-stepper': PyaQuantityStepper
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
.pya-card {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
overflow: hidden;
|
|
5
|
+
background: var(--pya-surface);
|
|
6
|
+
border: 1px solid var(--pya-border);
|
|
7
|
+
border-radius: var(--pya-radius-md);
|
|
8
|
+
box-shadow: var(--pya-shadow-sm);
|
|
9
|
+
text-decoration: none;
|
|
10
|
+
color: var(--pya-text);
|
|
11
|
+
transition: transform var(--pya-motion-fast) var(--pya-ease-standard);
|
|
12
|
+
/* Per-card VT name (injected via inline style by the renderer in
|
|
13
|
+
pages/index.astro + pages/stores/index.astro). When the user clicks a
|
|
14
|
+
card and the destination store page assigns the same name to its hero
|
|
15
|
+
cover, the browser morphs the rectangle into the cover. The thumb
|
|
16
|
+
fragment below grows from card-photo proportions to the 240px hero. */
|
|
17
|
+
/* Locked to the worst-case real card geometry (with 2 minitag pills) so
|
|
18
|
+
cards without optional pills don't shrink the row when they land. Skeleton
|
|
19
|
+
mirrors this exactly. 280 was too short — cards with both pills are 310. */
|
|
20
|
+
min-height: 310px;
|
|
21
|
+
box-sizing: border-box;
|
|
22
|
+
}
|
|
23
|
+
.pya-card:hover {
|
|
24
|
+
transform: translateY(-4px);
|
|
25
|
+
box-shadow: var(--pya-shadow);
|
|
26
|
+
}
|
|
27
|
+
/* Focus ring inherited from tokens/base.css. */
|
|
28
|
+
|
|
29
|
+
.pya-card__photo {
|
|
30
|
+
height: 156px;
|
|
31
|
+
display: grid;
|
|
32
|
+
place-items: center;
|
|
33
|
+
font-size: 64px;
|
|
34
|
+
position: relative;
|
|
35
|
+
/* Per-card photo VT name injected by the renderer — paired with
|
|
36
|
+
.store-cover on the detail page (same slug). */
|
|
37
|
+
}
|
|
38
|
+
.pya-card__emoji {
|
|
39
|
+
line-height: 1;
|
|
40
|
+
}
|
|
41
|
+
.pya-card__tag {
|
|
42
|
+
position: absolute;
|
|
43
|
+
top: 12px;
|
|
44
|
+
left: 12px;
|
|
45
|
+
/* Dark translucent pill works over any gradient thumb and keeps AAA
|
|
46
|
+
contrast — earlier --pya-acc2 yellow on white failed WCAG AA. */
|
|
47
|
+
background: rgba(0, 0, 0, 0.72);
|
|
48
|
+
color: #fff;
|
|
49
|
+
font-size: var(--pya-font-size-xs);
|
|
50
|
+
font-weight: 700;
|
|
51
|
+
padding: 4px 10px;
|
|
52
|
+
border-radius: var(--pya-radius-pill);
|
|
53
|
+
backdrop-filter: blur(4px);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.pya-card__body {
|
|
57
|
+
padding: var(--pya-space-3) var(--pya-space-4) var(--pya-space-4);
|
|
58
|
+
}
|
|
59
|
+
.pya-card__row {
|
|
60
|
+
display: flex;
|
|
61
|
+
justify-content: space-between;
|
|
62
|
+
align-items: center;
|
|
63
|
+
gap: var(--pya-space-2);
|
|
64
|
+
}
|
|
65
|
+
/* Explicit line-height = skeleton .pya-store-skel__name height (22px); the
|
|
66
|
+
browser default of ~1.4 made the real card 5px taller, which caused a CLS
|
|
67
|
+
spike on the skeleton → real swap. */
|
|
68
|
+
.pya-card__name {
|
|
69
|
+
font-size: var(--pya-font-size-lg);
|
|
70
|
+
font-weight: 700;
|
|
71
|
+
margin: 0;
|
|
72
|
+
line-height: 22px;
|
|
73
|
+
}
|
|
74
|
+
.pya-card__rating {
|
|
75
|
+
background: color-mix(in srgb, var(--pya-acc) 16%, transparent);
|
|
76
|
+
color: var(--pya-acc);
|
|
77
|
+
padding: 2px 9px;
|
|
78
|
+
border-radius: var(--pya-radius-pill);
|
|
79
|
+
font-weight: 700;
|
|
80
|
+
font-size: var(--pya-font-size-sm);
|
|
81
|
+
}
|
|
82
|
+
.pya-card__meta {
|
|
83
|
+
color: var(--pya-text-muted);
|
|
84
|
+
font-size: var(--pya-font-size-sm);
|
|
85
|
+
margin: var(--pya-space-2) 0 0;
|
|
86
|
+
}
|
|
87
|
+
.pya-card__tags {
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-wrap: wrap;
|
|
90
|
+
gap: 7px;
|
|
91
|
+
margin-top: var(--pya-space-3);
|
|
92
|
+
}
|
|
93
|
+
.pya-minitag {
|
|
94
|
+
font-size: var(--pya-font-size-xs);
|
|
95
|
+
font-weight: 600;
|
|
96
|
+
padding: 4px 10px;
|
|
97
|
+
border-radius: var(--pya-radius-pill);
|
|
98
|
+
background: var(--pya-surface-2);
|
|
99
|
+
color: var(--pya-text-muted);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Food gradient thumbs — migrated from mockup. */
|
|
103
|
+
.pya-thumb--burger {
|
|
104
|
+
background: linear-gradient(135deg, #ffd28a, #ff8a3d);
|
|
105
|
+
}
|
|
106
|
+
.pya-thumb--pizza {
|
|
107
|
+
background: linear-gradient(135deg, #ffb27a, #ef4f3a);
|
|
108
|
+
}
|
|
109
|
+
.pya-thumb--sushi {
|
|
110
|
+
background: linear-gradient(135deg, #a0e8c4, #16a34a);
|
|
111
|
+
}
|
|
112
|
+
.pya-thumb--taco {
|
|
113
|
+
background: linear-gradient(135deg, #ffe08a, #f59e0b);
|
|
114
|
+
}
|
|
115
|
+
.pya-thumb--chipa {
|
|
116
|
+
background: linear-gradient(135deg, #f7d9a8, #c98a3f);
|
|
117
|
+
}
|
|
118
|
+
.pya-thumb--asado {
|
|
119
|
+
background: linear-gradient(135deg, #ff9a76, #c0392b);
|
|
120
|
+
}
|
|
121
|
+
.pya-thumb--drink {
|
|
122
|
+
background: linear-gradient(135deg, #a5d8ff, #4c7ef3);
|
|
123
|
+
}
|
|
124
|
+
.pya-thumb--dessert {
|
|
125
|
+
background: linear-gradient(135deg, #ffc4e0, #e056a0);
|
|
126
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { LitElement, html, nothing } from 'lit'
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
3
|
+
import { LOCATORS } from './pya-store-card.locators.ts'
|
|
4
|
+
|
|
5
|
+
/** Photo-forward store card. Light DOM for a11y tree integrity. */
|
|
6
|
+
@customElement('pya-store-card')
|
|
7
|
+
export class PyaStoreCard extends LitElement {
|
|
8
|
+
protected override createRenderRoot(): HTMLElement {
|
|
9
|
+
return this
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@property({ type: String, reflect: true }) readonly slug = ''
|
|
13
|
+
@property({ type: String }) readonly name = ''
|
|
14
|
+
@property({ type: String }) readonly cuisineLabel = ''
|
|
15
|
+
@property({ type: String }) readonly emoji = ''
|
|
16
|
+
@property({ type: String }) readonly thumb = 'burger'
|
|
17
|
+
@property({ type: Number }) readonly etaMin = 0
|
|
18
|
+
@property({ type: Number }) readonly etaMax = 0
|
|
19
|
+
@property({ type: Number }) readonly rating = 0
|
|
20
|
+
@property({ type: String }) readonly promo = ''
|
|
21
|
+
@property({ type: Boolean }) readonly express = false
|
|
22
|
+
@property({ type: Boolean }) readonly freeShipping = false
|
|
23
|
+
@property({ type: String }) readonly href = ''
|
|
24
|
+
|
|
25
|
+
protected override render() {
|
|
26
|
+
const ariaLabel = `${this.name}, ${this.cuisineLabel}, ${this.etaMin} a ${this.etaMax} minutos, calificación ${this.rating}`
|
|
27
|
+
// Per-slug VT names — matched by .store-cover on the detail page so the
|
|
28
|
+
// photo morphs into the hero cover during cross-document view-transition.
|
|
29
|
+
const cardVtName = `card-${this.slug}`
|
|
30
|
+
const photoVtName = `card-photo-${this.slug}`
|
|
31
|
+
return html`
|
|
32
|
+
<a class="pya-card" href=${this.href} data-testid=${LOCATORS.root} aria-label=${ariaLabel}
|
|
33
|
+
style=${`view-transition-name: ${cardVtName}`}>
|
|
34
|
+
<div class=${`pya-card__photo pya-thumb--${this.thumb}`} aria-hidden="true"
|
|
35
|
+
style=${`view-transition-name: ${photoVtName}`}>
|
|
36
|
+
<span class="pya-card__emoji">${this.emoji}</span>
|
|
37
|
+
${this.promo !== '' ? html`<span class="pya-card__tag">${this.promo}</span>` : nothing}
|
|
38
|
+
</div>
|
|
39
|
+
<div class="pya-card__body">
|
|
40
|
+
<div class="pya-card__row">
|
|
41
|
+
<h3 class="pya-card__name" data-testid=${LOCATORS.name}>${this.name}</h3>
|
|
42
|
+
<span class="pya-card__rating" data-testid=${LOCATORS.rating}>★ ${this.rating}</span>
|
|
43
|
+
</div>
|
|
44
|
+
<p class="pya-card__meta">
|
|
45
|
+
${this.cuisineLabel} · <span data-testid=${LOCATORS.eta}>${this.etaMin}–${this.etaMax} min</span>
|
|
46
|
+
</p>
|
|
47
|
+
<div class="pya-card__tags">
|
|
48
|
+
${this.express ? html`<span class="pya-minitag">⚡ Express</span>` : nothing}
|
|
49
|
+
${this.freeShipping ? html`<span class="pya-minitag">🛵 Gratis</span>` : nothing}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</a>
|
|
53
|
+
`
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
declare global {
|
|
58
|
+
interface HTMLElementTagNameMap {
|
|
59
|
+
'pya-store-card': PyaStoreCard
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Singleton accessible announcer. Owns two ARIA live regions in <body> (polite
|
|
3
|
+
* and assertive). Lit components call `announcer.announce(msg)` instead of
|
|
4
|
+
* creating their own live regions (which screen readers may not pick up if
|
|
5
|
+
* injected dynamically).
|
|
6
|
+
*/
|
|
7
|
+
const ensureRegion = (priority: 'polite' | 'assertive'): HTMLElement => {
|
|
8
|
+
const id = `pya-live-${priority}`
|
|
9
|
+
const existing = document.getElementById(id)
|
|
10
|
+
if (existing !== null) return existing
|
|
11
|
+
const el = document.createElement('div')
|
|
12
|
+
el.id = id
|
|
13
|
+
el.setAttribute('role', priority === 'polite' ? 'status' : 'alert')
|
|
14
|
+
el.setAttribute('aria-live', priority)
|
|
15
|
+
el.setAttribute('aria-atomic', 'true')
|
|
16
|
+
el.className = 'visually-hidden'
|
|
17
|
+
document.body.appendChild(el)
|
|
18
|
+
return el
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const announcer = {
|
|
22
|
+
announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
|
|
23
|
+
const region = ensureRegion(priority)
|
|
24
|
+
region.textContent = ''
|
|
25
|
+
requestAnimationFrame(() => {
|
|
26
|
+
region.textContent = message
|
|
27
|
+
})
|
|
28
|
+
},
|
|
29
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cart store — client-side, localStorage-backed, pub/sub.
|
|
3
|
+
* Used by `<pya-cart-badge>`, `<pya-cart-drawer>`, and checkout page.
|
|
4
|
+
*/
|
|
5
|
+
const KEY = 'pya.cart'
|
|
6
|
+
|
|
7
|
+
export interface CartLine {
|
|
8
|
+
readonly itemId: string
|
|
9
|
+
readonly name: string
|
|
10
|
+
readonly priceGs: number
|
|
11
|
+
readonly emoji?: string
|
|
12
|
+
readonly qty: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CartState {
|
|
16
|
+
readonly storeId?: string
|
|
17
|
+
readonly storeName?: string
|
|
18
|
+
readonly lines: ReadonlyArray<CartLine>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Listener = (state: CartState) => void
|
|
22
|
+
|
|
23
|
+
const isLine = (v: unknown): v is CartLine =>
|
|
24
|
+
typeof v === 'object' &&
|
|
25
|
+
v !== null &&
|
|
26
|
+
typeof (v as { itemId?: unknown }).itemId === 'string' &&
|
|
27
|
+
typeof (v as { name?: unknown }).name === 'string' &&
|
|
28
|
+
typeof (v as { priceGs?: unknown }).priceGs === 'number' &&
|
|
29
|
+
typeof (v as { qty?: unknown }).qty === 'number'
|
|
30
|
+
|
|
31
|
+
const read = (): CartState => {
|
|
32
|
+
try {
|
|
33
|
+
const raw = globalThis.localStorage?.getItem(KEY)
|
|
34
|
+
if (raw === null || raw === undefined) return { lines: [] }
|
|
35
|
+
const parsed: unknown = JSON.parse(raw)
|
|
36
|
+
if (typeof parsed !== 'object' || parsed === null) return { lines: [] }
|
|
37
|
+
const obj = parsed as { storeId?: unknown; storeName?: unknown; lines?: unknown }
|
|
38
|
+
const lines = Array.isArray(obj.lines) ? obj.lines.filter(isLine) : []
|
|
39
|
+
const partial: CartState = { lines }
|
|
40
|
+
return typeof obj.storeId === 'string' && typeof obj.storeName === 'string'
|
|
41
|
+
? { storeId: obj.storeId, storeName: obj.storeName, lines }
|
|
42
|
+
: partial
|
|
43
|
+
} catch {
|
|
44
|
+
return { lines: [] }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const listeners = new Set<Listener>()
|
|
49
|
+
|
|
50
|
+
const write = (state: CartState): void => {
|
|
51
|
+
try {
|
|
52
|
+
globalThis.localStorage?.setItem(KEY, JSON.stringify(state))
|
|
53
|
+
} catch {
|
|
54
|
+
/* quota / SSR */
|
|
55
|
+
}
|
|
56
|
+
for (const l of listeners) l(state)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const cartStore = {
|
|
60
|
+
read,
|
|
61
|
+
subscribe(listener: Listener): () => void {
|
|
62
|
+
listeners.add(listener)
|
|
63
|
+
return () => {
|
|
64
|
+
listeners.delete(listener)
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
add(storeId: string, storeName: string, item: Omit<CartLine, 'qty'>, qty = 1): void {
|
|
68
|
+
const state = read()
|
|
69
|
+
if (state.storeId !== undefined && state.storeId !== storeId) {
|
|
70
|
+
// Single-store cart for MVP — replace if different store.
|
|
71
|
+
write({ storeId, storeName, lines: [{ ...item, qty }] })
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const existing = state.lines.find((l) => l.itemId === item.itemId)
|
|
75
|
+
const lines = existing
|
|
76
|
+
? state.lines.map((l) => (l.itemId === item.itemId ? { ...l, qty: l.qty + qty } : l))
|
|
77
|
+
: [...state.lines, { ...item, qty }]
|
|
78
|
+
write({ storeId, storeName, lines })
|
|
79
|
+
},
|
|
80
|
+
setQty(itemId: string, qty: number): void {
|
|
81
|
+
const state = read()
|
|
82
|
+
const lines = state.lines
|
|
83
|
+
.map((l) => (l.itemId === itemId ? { ...l, qty } : l))
|
|
84
|
+
.filter((l) => l.qty > 0)
|
|
85
|
+
write(lines.length === 0 ? { lines: [] } : { ...state, lines })
|
|
86
|
+
},
|
|
87
|
+
remove(itemId: string): void {
|
|
88
|
+
const state = read()
|
|
89
|
+
const lines = state.lines.filter((l) => l.itemId !== itemId)
|
|
90
|
+
write(lines.length === 0 ? { lines: [] } : { ...state, lines })
|
|
91
|
+
},
|
|
92
|
+
clear(): void {
|
|
93
|
+
write({ lines: [] })
|
|
94
|
+
},
|
|
95
|
+
count(): number {
|
|
96
|
+
return read().lines.reduce((s, l) => s + l.qty, 0)
|
|
97
|
+
},
|
|
98
|
+
subtotal(): number {
|
|
99
|
+
return read().lines.reduce((s, l) => s + l.priceGs * l.qty, 0)
|
|
100
|
+
},
|
|
101
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Pure UI helper for Guaraní formatting. Lit components import this rather
|
|
2
|
+
* than reaching into @pya-platform/shared for tree-shaking on islands.
|
|
3
|
+
* Locale read from <html lang> — Lit islands don't have framework context. */
|
|
4
|
+
const isEnLocale = (): boolean =>
|
|
5
|
+
typeof document !== 'undefined' && document.documentElement.lang.startsWith('en')
|
|
6
|
+
|
|
7
|
+
export const formatGs = (n: number): string => {
|
|
8
|
+
if (n === 0) return isEnLocale() ? 'Free' : 'Gratis'
|
|
9
|
+
return `Gs. ${n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.')}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const formatGsSpoken = (n: number): string => {
|
|
13
|
+
if (n === 0) return isEnLocale() ? 'free' : 'gratis'
|
|
14
|
+
const tag = isEnLocale() ? 'en-US' : 'es-PY'
|
|
15
|
+
const word = isEnLocale() ? 'guaraníes' : 'guaraníes'
|
|
16
|
+
return `${n.toLocaleString(tag)} ${word}`
|
|
17
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './components/pya-button/index.ts'
|
|
2
|
+
export * from './components/pya-store-card/index.ts'
|
|
3
|
+
export * from './components/pya-cart-badge/index.ts'
|
|
4
|
+
export * from './components/pya-quantity-stepper/index.ts'
|
|
5
|
+
export * from './components/pya-filter-chip/index.ts'
|
|
6
|
+
export { announcer } from './controllers/announcer.ts'
|
|
7
|
+
export { cartStore } from './controllers/cart-store.ts'
|
|
8
|
+
export type { CartLine, CartState } from './controllers/cart-store.ts'
|
|
9
|
+
export { formatGs, formatGsSpoken } from './helpers/format-money.ts'
|
package/tsconfig.json
ADDED