@ripple-ui/svelte 0.8.2 → 0.9.0-next.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/discover/CreatorAvatar.svelte +37 -0
- package/dist/discover/CreatorAvatar.svelte.d.ts +10 -0
- package/dist/discover/CreatorLine.svelte +18 -0
- package/dist/discover/CreatorLine.svelte.d.ts +8 -0
- package/dist/discover/DiscoverChips.svelte +15 -2
- package/dist/discover/DiscoverFilters.svelte +56 -41
- package/dist/discover/DiscoverHeader.svelte +33 -10
- package/dist/discover/DiscoverHeader.svelte.d.ts +3 -0
- package/dist/discover/DiscoverRow.svelte +36 -10
- package/dist/discover/DiscoverRow.svelte.d.ts +7 -0
- package/dist/discover/DiscoverSection.svelte +18 -10
- package/dist/discover/DiscoverSkeleton.svelte +5 -3
- package/dist/discover/DiscoverTile.svelte +52 -17
- package/dist/discover/DiscoverTile.svelte.d.ts +7 -0
- package/dist/discover/ItemActions.svelte +46 -17
- package/dist/discover/ItemActions.svelte.d.ts +6 -0
- package/dist/discover/ItemDetail.svelte +280 -0
- package/dist/discover/ItemDetail.svelte.d.ts +32 -0
- package/dist/discover/ItemMenu.svelte +61 -0
- package/dist/discover/ItemMenu.svelte.d.ts +13 -0
- package/dist/discover/LiveFrame.svelte +162 -0
- package/dist/discover/LiveFrame.svelte.d.ts +21 -0
- package/dist/discover/WhatsAppQrCard.svelte +302 -0
- package/dist/discover/WhatsAppQrCard.svelte.d.ts +18 -0
- package/dist/discover/frame.d.ts +7 -0
- package/dist/discover/frame.js +27 -0
- package/dist/discover/href.d.ts +7 -1
- package/dist/discover/href.js +16 -6
- package/dist/discover/index.d.ts +7 -2
- package/dist/discover/index.js +15 -7
- package/dist/discover/item.d.ts +14 -0
- package/dist/discover/item.js +41 -2
- package/dist/discover/layout.d.ts +1 -1
- package/dist/discover/layout.js +1 -1
- package/dist/discover/sections.d.ts +39 -0
- package/dist/discover/sections.js +84 -0
- package/dist/discover/types.d.ts +17 -0
- package/dist/discover/types.js +5 -2
- package/dist/discover/whatsapp-card-export.d.ts +9 -0
- package/dist/discover/whatsapp-card-export.js +72 -0
- package/dist/discover/whatsapp-card.d.ts +106 -0
- package/dist/discover/whatsapp-card.js +226 -0
- package/dist/discover/whatsapp-qr.d.ts +7 -0
- package/dist/discover/whatsapp-qr.js +20 -0
- package/dist/figures/bike-gears/figure.js +2 -6
- package/dist/figures/fission-reactor/figure.js +2 -6
- package/dist/figures/heart/figure.js +2 -6
- package/dist/figures/ion-thruster/figure.js +2 -6
- package/dist/figures/particle-collider/figure.js +2 -6
- package/dist/figures/rocket-engine/figure.js +2 -6
- package/dist/figures/style.d.ts +2 -0
- package/dist/figures/style.js +18 -0
- package/dist/figures/tokamak/figure.js +2 -6
- package/dist/manifest/entries/chart.js +1 -1
- package/dist/manifest/entries/markdown.js +2 -2
- package/dist/manifest.json +5 -5
- package/dist/styles.css +11 -10
- package/dist/utils/markdown-ext.d.ts +6 -0
- package/dist/utils/markdown-ext.js +68 -0
- package/dist/utils/mermaid.d.ts +94 -0
- package/dist/utils/mermaid.js +144 -0
- package/dist/utils/sanitize-html.d.ts +6 -0
- package/dist/utils/sanitize-html.js +15 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/widgets/data/Chart.svelte +34 -6
- package/dist/widgets/data/chart-layout.d.ts +4 -4
- package/dist/widgets/data/chart-layout.js +9 -8
- package/dist/widgets/display/Markdown.svelte +147 -3
- package/dist/widgets/display/Markdown.svelte.d.ts +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
<!-- discover/LiveFrame.svelte — a Discover item's live page (a site, a game, a
|
|
2
|
+
tool) inside the detail view. By default it starts as the poster (ItemArt)
|
|
3
|
+
and loads nothing: the iframe is created only when the visitor presses
|
|
4
|
+
"Load site", "Play" or "Open tool", so a grid, overlay or sheet never
|
|
5
|
+
pulls in a stranger's page on view. `autoload` (the item's own page)
|
|
6
|
+
renders the iframe straight away, in the server HTML too, so it loads
|
|
7
|
+
with JS off. `tall` sizes the frame for that page, min(72vh, 760px) high,
|
|
8
|
+
instead of 16:9. Sites get a Desktop / Phone width toggle once loaded
|
|
9
|
+
(phone is a 390px centred frame). "Open full screen" always links to the
|
|
10
|
+
page. Only an http(s) url is framed or linked; anything else leaves the
|
|
11
|
+
poster.
|
|
12
|
+
|
|
13
|
+
Server-safe: nothing touches window. The load button is drawn after
|
|
14
|
+
mount, so with JS off (and no autoload) the poster and the "Open full
|
|
15
|
+
screen" link are what shows, and the link works.
|
|
16
|
+
|
|
17
|
+
Sandbox (frameSandbox in frame.ts): allow-scripts allow-forms
|
|
18
|
+
allow-popups allow-pointer-lock, plus allow-same-origin only for an https
|
|
19
|
+
page on a subdomain of LIVE_FRAME_TRUSTED_HOST_SUFFIX
|
|
20
|
+
(.pawsites.workers.dev). Scripts plus same-origin only escape the sandbox
|
|
21
|
+
when the page shares the host's origin; each published site is its own
|
|
22
|
+
subdomain on a registrable domain apart from the website and the app, so
|
|
23
|
+
it keeps its own origin and storage (games save scores). Any other host
|
|
24
|
+
runs opaque, where storage throws; it still works through "Open full
|
|
25
|
+
screen". The link is user-submitted content, so it carries
|
|
26
|
+
rel="nofollow ugc noopener noreferrer". -->
|
|
27
|
+
<script lang="ts">
|
|
28
|
+
import { safeUrl } from '@ripple-ui/core';
|
|
29
|
+
import { onMount } from 'svelte';
|
|
30
|
+
import ExternalLink from '@lucide/svelte/icons/external-link';
|
|
31
|
+
import Monitor from '@lucide/svelte/icons/monitor';
|
|
32
|
+
import Play from '@lucide/svelte/icons/play';
|
|
33
|
+
import Smartphone from '@lucide/svelte/icons/smartphone';
|
|
34
|
+
import { Button } from '../components/ui/button/index.js';
|
|
35
|
+
import { cn } from '../utils.js';
|
|
36
|
+
import ItemArt from './ItemArt.svelte';
|
|
37
|
+
import { frameSandbox } from './frame.js';
|
|
38
|
+
import { httpUrl } from './item.js';
|
|
39
|
+
import type { DiscoverKind } from './types.js';
|
|
40
|
+
|
|
41
|
+
let {
|
|
42
|
+
url,
|
|
43
|
+
title,
|
|
44
|
+
kind,
|
|
45
|
+
poster = null,
|
|
46
|
+
tint,
|
|
47
|
+
initial,
|
|
48
|
+
autoload = false,
|
|
49
|
+
tall = false,
|
|
50
|
+
class: className,
|
|
51
|
+
}: {
|
|
52
|
+
/** The live page. Only http(s) is framed. */
|
|
53
|
+
url: string;
|
|
54
|
+
/** The item's title: the iframe's accessible name and the poster's alt. */
|
|
55
|
+
title: string;
|
|
56
|
+
kind: DiscoverKind;
|
|
57
|
+
/** The preview picture shown until the page loads. */
|
|
58
|
+
poster?: string | null;
|
|
59
|
+
/** The placeholder's tint and glyph when there is no poster (tintFor / initialFor). */
|
|
60
|
+
tint: string;
|
|
61
|
+
initial: string;
|
|
62
|
+
/** Render the iframe at once, server HTML included (the item's own page). */
|
|
63
|
+
autoload?: boolean;
|
|
64
|
+
/** The page's tall frame instead of a 16:9 strip. */
|
|
65
|
+
tall?: boolean;
|
|
66
|
+
class?: string;
|
|
67
|
+
} = $props();
|
|
68
|
+
|
|
69
|
+
const LOAD_LABEL: Partial<Record<DiscoverKind, string>> = { site: 'Load site', game: 'Play', tool: 'Open tool' };
|
|
70
|
+
|
|
71
|
+
const src = $derived(httpUrl(url));
|
|
72
|
+
// From the url, never a prop: see the header.
|
|
73
|
+
const sandbox = $derived(frameSandbox(src));
|
|
74
|
+
const loadLabel = $derived(LOAD_LABEL[kind] ?? 'Load');
|
|
75
|
+
let ready = $state(false);
|
|
76
|
+
let pressed = $state(false);
|
|
77
|
+
const loaded = $derived(autoload || pressed);
|
|
78
|
+
const TALL = 'h-[min(72vh,760px)] min-h-80';
|
|
79
|
+
let phone = $state(false);
|
|
80
|
+
|
|
81
|
+
onMount(() => {
|
|
82
|
+
ready = true;
|
|
83
|
+
});
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<div class={cn('flex flex-col gap-2', className)} data-testid="discover-live-frame">
|
|
87
|
+
{#if loaded && src}
|
|
88
|
+
<div
|
|
89
|
+
class={cn(
|
|
90
|
+
'overflow-hidden rounded-[10px] border border-border bg-background',
|
|
91
|
+
phone
|
|
92
|
+
? tall
|
|
93
|
+
? cn(TALL, 'mx-auto aspect-[390/844] w-auto max-w-full')
|
|
94
|
+
: 'mx-auto aspect-[390/844] max-h-[75vh] w-[390px] max-w-full'
|
|
95
|
+
: tall
|
|
96
|
+
? cn(TALL, 'w-full')
|
|
97
|
+
: 'aspect-video w-full',
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
<iframe
|
|
101
|
+
class="block h-full w-full border-0"
|
|
102
|
+
src={safeUrl(src, { kind: 'resource' })}
|
|
103
|
+
{title}
|
|
104
|
+
{sandbox}
|
|
105
|
+
allow="fullscreen; gamepad; autoplay"
|
|
106
|
+
loading={autoload ? 'eager' : 'lazy'}
|
|
107
|
+
referrerpolicy="no-referrer"
|
|
108
|
+
data-testid="discover-live-iframe"
|
|
109
|
+
></iframe>
|
|
110
|
+
</div>
|
|
111
|
+
{:else}
|
|
112
|
+
<div class="relative">
|
|
113
|
+
<ItemArt imageUrl={poster} {title} {tint} {initial} class={tall ? cn(TALL, 'aspect-auto') : undefined} />
|
|
114
|
+
{#if ready && src}
|
|
115
|
+
<div class="absolute inset-0 grid place-items-center">
|
|
116
|
+
<Button size="lg" class="rounded-full px-5 shadow-md" aria-label={`${loadLabel}: ${title}`} onclick={() => (pressed = true)}>
|
|
117
|
+
<Play class="size-4" aria-hidden="true" />{loadLabel}
|
|
118
|
+
</Button>
|
|
119
|
+
</div>
|
|
120
|
+
{/if}
|
|
121
|
+
</div>
|
|
122
|
+
{/if}
|
|
123
|
+
{#if src}
|
|
124
|
+
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
125
|
+
{#if kind === 'site' && loaded}
|
|
126
|
+
<div class="inline-flex rounded-lg border border-border p-0.5" role="group" aria-label="Preview width">
|
|
127
|
+
<Button
|
|
128
|
+
size="xs"
|
|
129
|
+
variant={phone ? 'ghost' : 'secondary'}
|
|
130
|
+
class={phone ? 'text-muted-foreground' : 'text-foreground'}
|
|
131
|
+
aria-pressed={!phone}
|
|
132
|
+
onclick={() => (phone = false)}
|
|
133
|
+
>
|
|
134
|
+
<Monitor aria-hidden="true" />Desktop
|
|
135
|
+
</Button>
|
|
136
|
+
<Button
|
|
137
|
+
size="xs"
|
|
138
|
+
variant={phone ? 'secondary' : 'ghost'}
|
|
139
|
+
class={phone ? 'text-foreground' : 'text-muted-foreground'}
|
|
140
|
+
aria-pressed={phone}
|
|
141
|
+
onclick={() => (phone = true)}
|
|
142
|
+
>
|
|
143
|
+
<Smartphone aria-hidden="true" />Phone
|
|
144
|
+
</Button>
|
|
145
|
+
</div>
|
|
146
|
+
{:else}
|
|
147
|
+
<span></span>
|
|
148
|
+
{/if}
|
|
149
|
+
<Button
|
|
150
|
+
variant="ghost"
|
|
151
|
+
size="sm"
|
|
152
|
+
class="text-foreground"
|
|
153
|
+
href={safeUrl(src)}
|
|
154
|
+
target="_blank"
|
|
155
|
+
rel="nofollow ugc noopener noreferrer"
|
|
156
|
+
aria-label={`Open ${title} full screen (opens in a new tab)`}
|
|
157
|
+
>
|
|
158
|
+
<ExternalLink aria-hidden="true" />Open full screen
|
|
159
|
+
</Button>
|
|
160
|
+
</div>
|
|
161
|
+
{/if}
|
|
162
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DiscoverKind } from './types.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
/** The live page. Only http(s) is framed. */
|
|
4
|
+
url: string;
|
|
5
|
+
/** The item's title: the iframe's accessible name and the poster's alt. */
|
|
6
|
+
title: string;
|
|
7
|
+
kind: DiscoverKind;
|
|
8
|
+
/** The preview picture shown until the page loads. */
|
|
9
|
+
poster?: string | null;
|
|
10
|
+
/** The placeholder's tint and glyph when there is no poster (tintFor / initialFor). */
|
|
11
|
+
tint: string;
|
|
12
|
+
initial: string;
|
|
13
|
+
/** Render the iframe at once, server HTML included (the item's own page). */
|
|
14
|
+
autoload?: boolean;
|
|
15
|
+
/** The page's tall frame instead of a 16:9 strip. */
|
|
16
|
+
tall?: boolean;
|
|
17
|
+
class?: string;
|
|
18
|
+
};
|
|
19
|
+
declare const LiveFrame: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
20
|
+
type LiveFrame = ReturnType<typeof LiveFrame>;
|
|
21
|
+
export default LiveFrame;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
<!-- discover/WhatsAppQrCard.svelte — the Discover hero's "Try one · no sign-up" widget:
|
|
2
|
+
a working WhatsApp QR card for a shop counter. The app's /discover and the public
|
|
3
|
+
Discover page both mount this one component (in DiscoverHeader's `aside`).
|
|
4
|
+
|
|
5
|
+
The shop name, a calling code (Select; default from the browser's languages, read
|
|
6
|
+
on mount, else India) and the number build a wa.me link with an optional
|
|
7
|
+
pre-filled message; a pasted "+44 7700 900123" picks the country itself. Until the
|
|
8
|
+
number is valid the preview shows a placeholder and every export is disabled, so
|
|
9
|
+
no QR ever points at a wrong number; the reason shows under the field (aria-live)
|
|
10
|
+
once the field is left. The preview is the printed card as it downloads: white
|
|
11
|
+
paper and dark ink in every theme (white/neutral classes, since paper is not
|
|
12
|
+
themed), tilted like a card on a counter (flat with reduced motion).
|
|
13
|
+
|
|
14
|
+
Server render: India and the example "Sharma Sweets" / "98765 43210", so a page
|
|
15
|
+
with JS off shows a real example card and link. On mount the number is cleared
|
|
16
|
+
and the country follows the browser. All logic is in ./whatsapp-card; exports in
|
|
17
|
+
./whatsapp-card-export (PNG 300 dpi, SVG in mm, Print one 148 × 105 mm page).
|
|
18
|
+
Host hooks: `oncopy`, `onopenlink`, `ondownload` replace the browser defaults
|
|
19
|
+
(clipboard, a target=_blank link, an anchor download); the "Make a full menu or
|
|
20
|
+
site" action renders only with `onmake` or `makeHref` (painted text-primary, the
|
|
21
|
+
host's own accent, so it reads on a host whose ripple tokens are the light set);
|
|
22
|
+
`brand` names the product.
|
|
23
|
+
|
|
24
|
+
Collapsed on narrow: the card is a <details> whose summary is a one-line
|
|
25
|
+
teaser ("Make a WhatsApp QR card for your shop" and a chevron). It sizes
|
|
26
|
+
off its own box (container `qr-card`): under 30rem (a phone) the details
|
|
27
|
+
start closed and the teaser opens them; from 30rem the teaser hides and
|
|
28
|
+
the content shows with the details still closed, through
|
|
29
|
+
::details-content, so the server markup is the same at every width and
|
|
30
|
+
hydration never jumps. Works with JS off. A browser without
|
|
31
|
+
::details-content keeps the teaser at every width; on mount, a wide one
|
|
32
|
+
opens the details instead. -->
|
|
33
|
+
<script lang="ts">
|
|
34
|
+
import { onMount } from 'svelte';
|
|
35
|
+
import { safeUrl } from '@ripple-ui/core';
|
|
36
|
+
import Check from '@lucide/svelte/icons/check';
|
|
37
|
+
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
|
38
|
+
import Copy from '@lucide/svelte/icons/copy';
|
|
39
|
+
import Download from '@lucide/svelte/icons/download';
|
|
40
|
+
import ExternalLink from '@lucide/svelte/icons/external-link';
|
|
41
|
+
import Printer from '@lucide/svelte/icons/printer';
|
|
42
|
+
import QrCode from '@lucide/svelte/icons/qr-code';
|
|
43
|
+
import { cn } from '../utils.js';
|
|
44
|
+
import { Badge } from '../components/ui/badge/index.js';
|
|
45
|
+
import { Button } from '../components/ui/button/index.js';
|
|
46
|
+
import { Input } from '../components/ui/input/index.js';
|
|
47
|
+
import { Label } from '../components/ui/label/index.js';
|
|
48
|
+
import * as Select from '../components/ui/select/index.js';
|
|
49
|
+
import {
|
|
50
|
+
DEFAULT_BRAND,
|
|
51
|
+
DEFAULT_DIAL_ISO,
|
|
52
|
+
DEFAULT_MESSAGE,
|
|
53
|
+
DEFAULT_SHOP,
|
|
54
|
+
DIAL_CODES,
|
|
55
|
+
EXAMPLE_PHONE,
|
|
56
|
+
MESSAGE_MAX,
|
|
57
|
+
SHOP_MAX,
|
|
58
|
+
cardFilename,
|
|
59
|
+
cardFooter,
|
|
60
|
+
cardLayout,
|
|
61
|
+
checkPhone,
|
|
62
|
+
defaultDialIso,
|
|
63
|
+
dialCodeFor,
|
|
64
|
+
splitInternational,
|
|
65
|
+
waLink,
|
|
66
|
+
} from './whatsapp-card.js';
|
|
67
|
+
import { cardPng, cardSvgBlob, downloadBlob, printCard } from './whatsapp-card-export.js';
|
|
68
|
+
|
|
69
|
+
let {
|
|
70
|
+
brand = DEFAULT_BRAND,
|
|
71
|
+
oncopy,
|
|
72
|
+
onopenlink,
|
|
73
|
+
ondownload,
|
|
74
|
+
onmake,
|
|
75
|
+
makeHref,
|
|
76
|
+
class: className,
|
|
77
|
+
}: {
|
|
78
|
+
/** The product named in "Make a full menu or site with …" and on the card's foot. */
|
|
79
|
+
brand?: string;
|
|
80
|
+
/** Copy the wa.me link. Default: the clipboard API. */
|
|
81
|
+
oncopy?: (text: string) => Promise<void> | void;
|
|
82
|
+
/** Open the wa.me link ("Test it"). Default: a plain target=_blank link. */
|
|
83
|
+
onopenlink?: (url: string) => void;
|
|
84
|
+
/** Save a PNG or SVG. Default: an anchor download of a blob URL. */
|
|
85
|
+
ondownload?: (blob: Blob, filename: string) => void;
|
|
86
|
+
/** "Make a full menu or site", with the typed shop name. */
|
|
87
|
+
onmake?: (shop: string) => void;
|
|
88
|
+
/** The same action as a link, for a host without a callback. */
|
|
89
|
+
makeHref?: string;
|
|
90
|
+
class?: string;
|
|
91
|
+
} = $props();
|
|
92
|
+
|
|
93
|
+
const uid = $props.id();
|
|
94
|
+
|
|
95
|
+
let shop = $state(DEFAULT_SHOP);
|
|
96
|
+
let iso = $state(DEFAULT_DIAL_ISO);
|
|
97
|
+
let phone = $state(EXAMPLE_PHONE);
|
|
98
|
+
let message = $state(DEFAULT_MESSAGE);
|
|
99
|
+
let touched = $state(false);
|
|
100
|
+
let copied = $state(false);
|
|
101
|
+
let note = $state<string | null>(null);
|
|
102
|
+
let details = $state<HTMLDetailsElement>();
|
|
103
|
+
|
|
104
|
+
/** Width (px) of the card's own box from which it shows in full: 30rem. */
|
|
105
|
+
const FULL_FROM = 480;
|
|
106
|
+
|
|
107
|
+
onMount(() => {
|
|
108
|
+
iso = defaultDialIso(navigator.languages);
|
|
109
|
+
phone = '';
|
|
110
|
+
// Without ::details-content the CSS cannot show a closed card's body, so a
|
|
111
|
+
// wide card opens itself. Browsers that have it never change state here.
|
|
112
|
+
const box = details?.parentElement;
|
|
113
|
+
if (details && box && !CSS.supports?.('selector(::details-content)') && box.clientWidth >= FULL_FROM) details.open = true;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const dial = $derived(dialCodeFor(iso));
|
|
117
|
+
const check = $derived(checkPhone(dial.code, phone));
|
|
118
|
+
const link = $derived(check.ok ? waLink(check.digits, message) : null);
|
|
119
|
+
const layout = $derived(check.ok && link ? cardLayout({ shop, line: check.line, link, brand }) : null);
|
|
120
|
+
const error = $derived(!check.ok && touched ? check.error : null);
|
|
121
|
+
|
|
122
|
+
/** A pasted or typed international number picks its country. */
|
|
123
|
+
function onPhoneInput(e: Event & { currentTarget: HTMLInputElement }) {
|
|
124
|
+
const split = splitInternational(e.currentTarget.value, iso);
|
|
125
|
+
if (!split) return;
|
|
126
|
+
iso = split.iso;
|
|
127
|
+
phone = split.national;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const save = (blob: Blob, filename: string) => (ondownload ?? downloadBlob)(blob, filename);
|
|
131
|
+
|
|
132
|
+
async function savePng() {
|
|
133
|
+
if (!layout) return;
|
|
134
|
+
note = null;
|
|
135
|
+
try {
|
|
136
|
+
save(await cardPng(layout), cardFilename(shop, 'png'));
|
|
137
|
+
} catch {
|
|
138
|
+
note = "Couldn't make the PNG. Try the SVG or Print.";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function saveSvg() {
|
|
142
|
+
if (layout) save(cardSvgBlob(layout), cardFilename(shop, 'svg'));
|
|
143
|
+
}
|
|
144
|
+
function print() {
|
|
145
|
+
if (layout) printCard(layout, `${shop.trim() || 'Shop'} WhatsApp QR card`);
|
|
146
|
+
}
|
|
147
|
+
async function copy(text: string) {
|
|
148
|
+
note = null;
|
|
149
|
+
try {
|
|
150
|
+
await (oncopy ?? ((t: string) => navigator.clipboard.writeText(t)))(text);
|
|
151
|
+
copied = true;
|
|
152
|
+
setTimeout(() => (copied = false), 1500);
|
|
153
|
+
} catch {
|
|
154
|
+
note = "Couldn't copy the link.";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
</script>
|
|
158
|
+
|
|
159
|
+
<div class={cn('@container/qr-card w-full min-w-0 justify-self-center', className)} data-testid="discover-qr-card">
|
|
160
|
+
<details class="qr-details group/qr mx-auto w-full max-w-[440px]" bind:this={details} data-testid="discover-qr-details">
|
|
161
|
+
<summary
|
|
162
|
+
class="qr-teaser flex cursor-pointer list-none items-center gap-3 rounded-2xl border border-border bg-card px-4 py-3 text-card-foreground shadow-sm select-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none group-open/qr:mb-3 [&::-webkit-details-marker]:hidden"
|
|
163
|
+
data-testid="discover-qr-teaser"
|
|
164
|
+
>
|
|
165
|
+
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-muted text-foreground" aria-hidden="true"><QrCode class="size-4" /></span>
|
|
166
|
+
<span class="min-w-0 flex-1 text-callout font-medium text-foreground">Make a WhatsApp QR card for your shop</span>
|
|
167
|
+
<ChevronDown
|
|
168
|
+
class="size-4 shrink-0 text-muted-foreground transition-transform duration-150 group-open/qr:rotate-180 motion-reduce:transition-none"
|
|
169
|
+
aria-hidden="true"
|
|
170
|
+
/>
|
|
171
|
+
</summary>
|
|
172
|
+
<div class="relative w-full pt-3">
|
|
173
|
+
<Badge class="absolute top-0 left-6 z-10 uppercase">Try one · no sign-up</Badge>
|
|
174
|
+
<div class="flex flex-col gap-3 rounded-2xl border border-border bg-card p-5 text-card-foreground shadow-sm">
|
|
175
|
+
<div>
|
|
176
|
+
<h2 class="text-title-3 font-semibold text-foreground">WhatsApp QR card</h2>
|
|
177
|
+
<p class="text-subheadline text-muted-foreground">Customers scan it and land in your WhatsApp chat.</p>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div class="grid items-start gap-3 sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
|
181
|
+
<div class="flex min-w-0 flex-col gap-1.5">
|
|
182
|
+
<Label for="{uid}-shop">Shop name</Label>
|
|
183
|
+
<Input id="{uid}-shop" bind:value={shop} maxlength={SHOP_MAX} placeholder="Your shop name" />
|
|
184
|
+
</div>
|
|
185
|
+
<div class="flex min-w-0 flex-col gap-1.5">
|
|
186
|
+
<Label for="{uid}-phone">WhatsApp number</Label>
|
|
187
|
+
<div class="flex gap-1.5">
|
|
188
|
+
<Select.Root type="single" value={iso} onValueChange={(v) => (iso = v)}>
|
|
189
|
+
<Select.Trigger class="w-[5.5rem] shrink-0 tabular-nums" aria-label={`Country code, ${dial.name}`} data-testid="discover-qr-country">
|
|
190
|
+
{dial.iso} +{dial.code}
|
|
191
|
+
</Select.Trigger>
|
|
192
|
+
<Select.Content class="max-h-72">
|
|
193
|
+
{#each DIAL_CODES as d (d.iso)}
|
|
194
|
+
<Select.Item value={d.iso} label={`${d.name} +${d.code}`}>{d.name} <span class="text-muted-foreground tabular-nums">+{d.code}</span></Select.Item>
|
|
195
|
+
{/each}
|
|
196
|
+
</Select.Content>
|
|
197
|
+
</Select.Root>
|
|
198
|
+
<Input
|
|
199
|
+
id="{uid}-phone"
|
|
200
|
+
class="min-w-0 tabular-nums"
|
|
201
|
+
bind:value={phone}
|
|
202
|
+
oninput={onPhoneInput}
|
|
203
|
+
onblur={() => (touched = true)}
|
|
204
|
+
inputmode="tel"
|
|
205
|
+
autocomplete="tel-national"
|
|
206
|
+
maxlength={24}
|
|
207
|
+
placeholder={EXAMPLE_PHONE}
|
|
208
|
+
aria-invalid={error ? true : undefined}
|
|
209
|
+
aria-describedby="{uid}-phone-note"
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
212
|
+
<p id="{uid}-phone-note" class={cn(error ? 'text-footnote text-ripple-error-text' : 'sr-only')} aria-live="polite">{error ?? ''}</p>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div class="flex flex-col gap-1.5">
|
|
217
|
+
<Label for="{uid}-message">First message <span class="font-normal text-muted-foreground">optional</span></Label>
|
|
218
|
+
<Input id="{uid}-message" bind:value={message} maxlength={MESSAGE_MAX} placeholder="What customers send when they scan" />
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div
|
|
222
|
+
class="relative mx-auto grid aspect-[1.75/1] w-full -rotate-1 grid-cols-[1fr_auto] items-center gap-3.5 rounded-md border border-neutral-200 bg-white px-5 pt-4 pb-7 text-neutral-900 shadow-md transition-transform duration-150 hover:rotate-0 motion-reduce:rotate-0 motion-reduce:transition-none sm:w-[88%]"
|
|
223
|
+
aria-live="polite"
|
|
224
|
+
data-testid="discover-qr-preview"
|
|
225
|
+
>
|
|
226
|
+
<div class="min-w-0">
|
|
227
|
+
<div class="text-title-3 font-bold! break-words sm:text-title-2" data-testid="discover-qr-shop">{shop.trim() || 'Your shop'}</div>
|
|
228
|
+
<div class="mt-1.5 text-callout text-neutral-800 tabular-nums" data-testid="discover-qr-line">
|
|
229
|
+
{check.ok ? check.line : 'Add your WhatsApp number'}
|
|
230
|
+
</div>
|
|
231
|
+
<div class="mt-3 text-footnote font-bold text-neutral-900">Scan to order on WhatsApp →</div>
|
|
232
|
+
</div>
|
|
233
|
+
{#if layout}
|
|
234
|
+
<svg
|
|
235
|
+
class="size-20 bg-white text-neutral-900 sm:size-24"
|
|
236
|
+
viewBox="0 0 {layout.qr.path.size} {layout.qr.path.size}"
|
|
237
|
+
shape-rendering="crispEdges"
|
|
238
|
+
role="img"
|
|
239
|
+
aria-label={`QR code that opens a WhatsApp chat with ${check.ok ? check.line : ''}`}
|
|
240
|
+
data-testid="discover-qr-code"
|
|
241
|
+
>
|
|
242
|
+
<path d={layout.qr.path.d} fill="currentColor" />
|
|
243
|
+
</svg>
|
|
244
|
+
{:else}
|
|
245
|
+
<div
|
|
246
|
+
class="grid size-20 place-items-center rounded-sm border-2 border-dashed border-neutral-300 bg-neutral-50 px-1 text-center text-caption-1 text-neutral-600 sm:size-24"
|
|
247
|
+
data-testid="discover-qr-placeholder"
|
|
248
|
+
>
|
|
249
|
+
QR appears when the number is complete
|
|
250
|
+
</div>
|
|
251
|
+
{/if}
|
|
252
|
+
<div class="absolute bottom-1.5 left-5 text-caption-1 text-neutral-500">{cardFooter(brand)}</div>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div class="flex flex-wrap gap-2">
|
|
256
|
+
<Button disabled={!layout} onclick={() => void savePng()} data-testid="discover-qr-png"><Download />Download PNG</Button>
|
|
257
|
+
<Button variant="outline" disabled={!layout} onclick={saveSvg} data-testid="discover-qr-svg">SVG</Button>
|
|
258
|
+
<Button variant="outline" disabled={!layout} onclick={print} data-testid="discover-qr-print"><Printer />Print</Button>
|
|
259
|
+
</div>
|
|
260
|
+
{#if note}<p class="text-footnote text-ripple-error-text" role="status">{note}</p>{/if}
|
|
261
|
+
|
|
262
|
+
{#if link}
|
|
263
|
+
<div class="flex min-w-0 items-center gap-1 rounded-lg bg-muted px-2.5 py-1 text-footnote">
|
|
264
|
+
<span class="min-w-0 flex-1 truncate text-muted-foreground" title={link} data-testid="discover-qr-link">{link}</span>
|
|
265
|
+
<Button variant="ghost" size="icon-xs" aria-label="Copy the WhatsApp link" onclick={() => void copy(link)}>
|
|
266
|
+
{#if copied}<Check />{:else}<Copy />{/if}
|
|
267
|
+
</Button>
|
|
268
|
+
{#if onopenlink}
|
|
269
|
+
<Button variant="ghost" size="xs" onclick={() => onopenlink(link)}><ExternalLink />Test it</Button>
|
|
270
|
+
{:else}
|
|
271
|
+
<Button variant="ghost" size="xs" href={safeUrl(link)} target="_blank" rel="noopener noreferrer"><ExternalLink />Test it</Button>
|
|
272
|
+
{/if}
|
|
273
|
+
</div>
|
|
274
|
+
{/if}
|
|
275
|
+
|
|
276
|
+
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-footnote text-muted-foreground">
|
|
277
|
+
<span>Free · runs in your browser</span>
|
|
278
|
+
{#if onmake}
|
|
279
|
+
<Button variant="link" size="xs" class="h-auto px-0 text-primary" onclick={() => onmake(shop)}>Make a full menu or site with {brand}</Button>
|
|
280
|
+
{:else if makeHref}
|
|
281
|
+
<Button variant="link" size="xs" class="h-auto px-0 text-primary" href={safeUrl(makeHref)}>Make a full menu or site with {brand}</Button>
|
|
282
|
+
{/if}
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</details>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
<style>
|
|
290
|
+
/* From 30rem of the card's own box: no teaser, the body shows while the
|
|
291
|
+
details stay closed (so the server markup never changes with width). */
|
|
292
|
+
@container qr-card (min-width: 30rem) {
|
|
293
|
+
@supports selector(::details-content) {
|
|
294
|
+
.qr-details > summary {
|
|
295
|
+
display: none;
|
|
296
|
+
}
|
|
297
|
+
.qr-details::details-content {
|
|
298
|
+
content-visibility: visible;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
/** The product named in "Make a full menu or site with …" and on the card's foot. */
|
|
3
|
+
brand?: string;
|
|
4
|
+
/** Copy the wa.me link. Default: the clipboard API. */
|
|
5
|
+
oncopy?: (text: string) => Promise<void> | void;
|
|
6
|
+
/** Open the wa.me link ("Test it"). Default: a plain target=_blank link. */
|
|
7
|
+
onopenlink?: (url: string) => void;
|
|
8
|
+
/** Save a PNG or SVG. Default: an anchor download of a blob URL. */
|
|
9
|
+
ondownload?: (blob: Blob, filename: string) => void;
|
|
10
|
+
/** "Make a full menu or site", with the typed shop name. */
|
|
11
|
+
onmake?: (shop: string) => void;
|
|
12
|
+
/** The same action as a link, for a host without a callback. */
|
|
13
|
+
makeHref?: string;
|
|
14
|
+
class?: string;
|
|
15
|
+
};
|
|
16
|
+
declare const WhatsAppQrCard: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
17
|
+
type WhatsAppQrCard = ReturnType<typeof WhatsAppQrCard>;
|
|
18
|
+
export default WhatsAppQrCard;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Hostname suffix whose pages keep their own origin in the frame. The leading dot
|
|
2
|
+
* makes it a subdomain match: the bare domain and look-alikes do not qualify. */
|
|
3
|
+
export declare const LIVE_FRAME_TRUSTED_HOST_SUFFIX = ".pawsites.workers.dev";
|
|
4
|
+
/** True when `url` is https on a subdomain of the trusted suffix. */
|
|
5
|
+
export declare function isTrustedFrameHost(url: string | null | undefined): boolean;
|
|
6
|
+
/** The iframe sandbox for a live page at `url`. */
|
|
7
|
+
export declare const frameSandbox: (url: string | null | undefined) => string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// discover/frame.ts — the sandbox LiveFrame puts on a live page. Every page
|
|
2
|
+
// gets allow-scripts allow-forms allow-popups allow-pointer-lock. A page on a
|
|
3
|
+
// trusted host (https, hostname ending in LIVE_FRAME_TRUSTED_HOST_SUFFIX) also
|
|
4
|
+
// gets allow-same-origin, so it keeps its own storage: games save scores in
|
|
5
|
+
// localStorage. Scripts plus same-origin only escape the sandbox when the
|
|
6
|
+
// framed page shares the embedder's origin; each published site is its own
|
|
7
|
+
// subdomain of pawsites.workers.dev, a registrable domain apart from
|
|
8
|
+
// pocketpaw.xyz and the app, so its origin is never the host's. Any other
|
|
9
|
+
// host stays opaque.
|
|
10
|
+
/** Hostname suffix whose pages keep their own origin in the frame. The leading dot
|
|
11
|
+
* makes it a subdomain match: the bare domain and look-alikes do not qualify. */
|
|
12
|
+
export const LIVE_FRAME_TRUSTED_HOST_SUFFIX = '.pawsites.workers.dev';
|
|
13
|
+
const BASE_SANDBOX = 'allow-scripts allow-forms allow-popups allow-pointer-lock';
|
|
14
|
+
/** True when `url` is https on a subdomain of the trusted suffix. */
|
|
15
|
+
export function isTrustedFrameHost(url) {
|
|
16
|
+
if (!url)
|
|
17
|
+
return false;
|
|
18
|
+
try {
|
|
19
|
+
const u = new URL(url);
|
|
20
|
+
return u.protocol === 'https:' && u.hostname.endsWith(LIVE_FRAME_TRUSTED_HOST_SUFFIX);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** The iframe sandbox for a live page at `url`. */
|
|
27
|
+
export const frameSandbox = (url) => isTrustedFrameHost(url) ? `${BASE_SANDBOX} allow-same-origin` : BASE_SANDBOX;
|
package/dist/discover/href.d.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
import type { DiscoverKind } from './types.js';
|
|
2
|
-
|
|
2
|
+
/** Where items of each kind used to live, before every kind moved to /discover/. */
|
|
3
|
+
export declare const LEGACY_ITEM_PREFIXES: Readonly<Partial<Record<DiscoverKind, string>>>;
|
|
4
|
+
/** The item's page. `kind` is kept so call sites read the same as before; every kind routes alike. */
|
|
5
|
+
export declare function itemHref(_kind: DiscoverKind, idOrSlug: string): string;
|
|
6
|
+
/** A plain primary-button click with no modifier: the one a card may take over to
|
|
7
|
+
* open an overlay. Ctrl/cmd/shift/alt-clicks and middle clicks keep the link. */
|
|
8
|
+
export declare function isPlainClick(e: Pick<MouseEvent, 'button' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'defaultPrevented'>): boolean;
|
package/dist/discover/href.js
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
// discover/href.ts — the public route for one Discover item
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
// discover/href.ts — the public route for one Discover item: /discover/<slug>
|
|
2
|
+
// for every kind, so the website and the app agree on one URL per item. The
|
|
3
|
+
// old per-kind folders are kept as LEGACY_ITEM_PREFIXES for the website's
|
|
4
|
+
// redirects from the routes it used to serve. isPlainClick decides when a card
|
|
5
|
+
// link may be taken over to open an overlay instead.
|
|
6
|
+
/** Where items of each kind used to live, before every kind moved to /discover/. */
|
|
7
|
+
export const LEGACY_ITEM_PREFIXES = Object.freeze({
|
|
4
8
|
tool: '/free-tools/',
|
|
5
9
|
game: '/play/',
|
|
6
10
|
site: '/templates/',
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
});
|
|
12
|
+
/** The item's page. `kind` is kept so call sites read the same as before; every kind routes alike. */
|
|
13
|
+
export function itemHref(_kind, idOrSlug) {
|
|
14
|
+
return `/discover/${encodeURIComponent(idOrSlug)}`;
|
|
15
|
+
}
|
|
16
|
+
/** A plain primary-button click with no modifier: the one a card may take over to
|
|
17
|
+
* open an overlay. Ctrl/cmd/shift/alt-clicks and middle clicks keep the link. */
|
|
18
|
+
export function isPlainClick(e) {
|
|
19
|
+
return e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.defaultPrevented;
|
|
10
20
|
}
|
package/dist/discover/index.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export { default as DiscoverTile } from './DiscoverTile.svelte';
|
|
|
2
2
|
export { default as DiscoverRow } from './DiscoverRow.svelte';
|
|
3
3
|
export { default as ItemArt } from './ItemArt.svelte';
|
|
4
4
|
export { default as DetailMedia } from './DetailMedia.svelte';
|
|
5
|
+
export { default as ItemDetail } from './ItemDetail.svelte';
|
|
6
|
+
export { default as LiveFrame } from './LiveFrame.svelte';
|
|
5
7
|
export { default as DiscoverHeader } from './DiscoverHeader.svelte';
|
|
6
8
|
export { default as DiscoverSearch } from './DiscoverSearch.svelte';
|
|
7
9
|
export { default as DiscoverChips } from './DiscoverChips.svelte';
|
|
@@ -11,8 +13,11 @@ export { default as DiscoverGrid } from './DiscoverGrid.svelte';
|
|
|
11
13
|
export { default as DiscoverSkeleton } from './DiscoverSkeleton.svelte';
|
|
12
14
|
export { default as DiscoverEmpty } from './DiscoverEmpty.svelte';
|
|
13
15
|
export { default as DiscoverPublish } from './DiscoverPublish.svelte';
|
|
14
|
-
export {
|
|
16
|
+
export { default as WhatsAppQrCard } from './WhatsAppQrCard.svelte';
|
|
17
|
+
export { itemHref, isPlainClick, LEGACY_ITEM_PREFIXES } from './href.js';
|
|
15
18
|
export { tintFor, initialFor, CARD_PALETTE } from './art.js';
|
|
16
|
-
export { isStudioKind, primaryFor, mediaFor, usageLabel, remixLabel, usedLabel, type DiscoverPrimary } from './item.js';
|
|
19
|
+
export { isStudioKind, httpUrl, primaryFor, mediaFor, rendererFor, usageLabel, showUsage, remixLabel, usedLabel, formatDiscoverDate, publishedDate, updatedDate, creatorInitials, type DiscoverPrimary, type DiscoverRenderer, } from './item.js';
|
|
20
|
+
export { DISCOVER_TYPES, DISCOVER_AUDIENCES, discoverSections, groupSections, sectionFilter, relatedHeading, type DiscoverSectionKey, type DiscoverAudience, type DiscoverSectionCopy, type GroupableItem, } from './sections.js';
|
|
21
|
+
export { LIVE_FRAME_TRUSTED_HOST_SUFFIX, frameSandbox, isTrustedFrameHost } from './frame.js';
|
|
17
22
|
export { DISCOVER_VIEWS, type DiscoverView, type DiscoverOption } from './layout.js';
|
|
18
23
|
export type { DiscoverItem, DiscoverKind, DiscoverMediaKind, StudioKind } from './types.js';
|
package/dist/discover/index.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
// discover/index.ts — the `./discover` export subpath: a server-renderable
|
|
2
|
-
// Discover
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// Discover library for a public catalogue. The cards (tile, row, art), the
|
|
3
|
+
// item view (ItemDetail with its live frame and media player), the page
|
|
4
|
+
// layout around them (header, search, chips, type bar, sections, skeleton,
|
|
5
|
+
// empty state, publish band), the hero's WhatsApp QR card (WhatsAppQrCard,
|
|
6
|
+
// the one stateful widget: a form, rendering an example card on the server),
|
|
7
|
+
// the shared catalogue copy and grouping, the item type they render, and the
|
|
8
|
+
// route helper for an item's own page. Pure components: props in, markup out;
|
|
9
|
+
// every control works by callback or as a plain link.
|
|
7
10
|
export { default as DiscoverTile } from './DiscoverTile.svelte';
|
|
8
11
|
export { default as DiscoverRow } from './DiscoverRow.svelte';
|
|
9
12
|
export { default as ItemArt } from './ItemArt.svelte';
|
|
10
13
|
export { default as DetailMedia } from './DetailMedia.svelte';
|
|
14
|
+
export { default as ItemDetail } from './ItemDetail.svelte';
|
|
15
|
+
export { default as LiveFrame } from './LiveFrame.svelte';
|
|
11
16
|
export { default as DiscoverHeader } from './DiscoverHeader.svelte';
|
|
12
17
|
export { default as DiscoverSearch } from './DiscoverSearch.svelte';
|
|
13
18
|
export { default as DiscoverChips } from './DiscoverChips.svelte';
|
|
@@ -17,7 +22,10 @@ export { default as DiscoverGrid } from './DiscoverGrid.svelte';
|
|
|
17
22
|
export { default as DiscoverSkeleton } from './DiscoverSkeleton.svelte';
|
|
18
23
|
export { default as DiscoverEmpty } from './DiscoverEmpty.svelte';
|
|
19
24
|
export { default as DiscoverPublish } from './DiscoverPublish.svelte';
|
|
20
|
-
export {
|
|
25
|
+
export { default as WhatsAppQrCard } from './WhatsAppQrCard.svelte';
|
|
26
|
+
export { itemHref, isPlainClick, LEGACY_ITEM_PREFIXES } from './href.js';
|
|
21
27
|
export { tintFor, initialFor, CARD_PALETTE } from './art.js';
|
|
22
|
-
export { isStudioKind, primaryFor, mediaFor, usageLabel, remixLabel, usedLabel } from './item.js';
|
|
28
|
+
export { isStudioKind, httpUrl, primaryFor, mediaFor, rendererFor, usageLabel, showUsage, remixLabel, usedLabel, formatDiscoverDate, publishedDate, updatedDate, creatorInitials, } from './item.js';
|
|
29
|
+
export { DISCOVER_TYPES, DISCOVER_AUDIENCES, discoverSections, groupSections, sectionFilter, relatedHeading, } from './sections.js';
|
|
30
|
+
export { LIVE_FRAME_TRUSTED_HOST_SUFFIX, frameSandbox, isTrustedFrameHost } from './frame.js';
|
|
23
31
|
export { DISCOVER_VIEWS } from './layout.js';
|