@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
package/dist/discover/item.d.ts
CHANGED
|
@@ -11,8 +11,22 @@ export declare function remixLabel(count: number): string;
|
|
|
11
11
|
/** Studio listings count recipe opens, not copies. */
|
|
12
12
|
export declare function usedLabel(count: number): string;
|
|
13
13
|
export declare const usageLabel: (item: DiscoverItem) => string;
|
|
14
|
+
/** False at zero, so a card can drop "No remixes yet" instead of printing it. */
|
|
15
|
+
export declare const showUsage: (item: DiscoverItem) => boolean;
|
|
14
16
|
export declare function mediaFor(item: DiscoverItem): {
|
|
15
17
|
kind: DiscoverMediaKind;
|
|
16
18
|
url: string;
|
|
17
19
|
} | null;
|
|
18
20
|
export declare function primaryFor(item: DiscoverItem): DiscoverPrimary | null;
|
|
21
|
+
/** How the detail view shows an item: the studio player, the live page in a
|
|
22
|
+
* frame, or the picture. Unknown kinds and items with nothing to play get the art. */
|
|
23
|
+
export type DiscoverRenderer = 'media' | 'frame' | 'art';
|
|
24
|
+
export declare function rendererFor(item: DiscoverItem): DiscoverRenderer;
|
|
25
|
+
/** "Oct 22, 2026" for an ISO timestamp, the same on every machine; null when absent or unparseable. */
|
|
26
|
+
export declare function formatDiscoverDate(iso: string | null | undefined): string | null;
|
|
27
|
+
/** When the item was first published: `createdAt`, else the last publish. */
|
|
28
|
+
export declare const publishedDate: (item: DiscoverItem) => string | null;
|
|
29
|
+
/** The update date, only when it is more than a day after the first publish. */
|
|
30
|
+
export declare function updatedDate(item: DiscoverItem): string | null;
|
|
31
|
+
/** Up to two initials for an avatar fallback: "Asha Rao" -> "AR", "asha" -> "A". */
|
|
32
|
+
export declare function creatorInitials(name: string): string;
|
package/dist/discover/item.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// discover/item.ts — what a tile
|
|
2
|
-
//
|
|
1
|
+
// discover/item.ts — what a tile, a row or the detail view derives from a
|
|
2
|
+
// DiscoverItem: the studio flag, the usage line (and whether to show it), the
|
|
3
|
+
// playable media, the primary action, and which renderer the detail uses. Pure
|
|
3
4
|
// functions, so the components stay props-in markup-out and the rules are
|
|
4
5
|
// testable without a DOM. Only http(s) urls survive into an href or a src.
|
|
5
6
|
export const isStudioKind = (kind) => kind === 'image' || kind === 'video' || kind === 'music';
|
|
@@ -29,6 +30,8 @@ export function usedLabel(count) {
|
|
|
29
30
|
return `${compact.format(count).toLowerCase()} used`;
|
|
30
31
|
}
|
|
31
32
|
export const usageLabel = (item) => isStudioKind(item.kind) ? usedLabel(item.remixCount) : remixLabel(item.remixCount);
|
|
33
|
+
/** False at zero, so a card can drop "No remixes yet" instead of printing it. */
|
|
34
|
+
export const showUsage = (item) => Number.isFinite(item.remixCount) && item.remixCount > 0;
|
|
32
35
|
export function mediaFor(item) {
|
|
33
36
|
const url = isStudioKind(item.kind) ? httpUrl(item.mediaUrl) : null;
|
|
34
37
|
return url && item.mediaKind ? { kind: item.mediaKind, url } : null;
|
|
@@ -43,3 +46,39 @@ export function primaryFor(item) {
|
|
|
43
46
|
return null;
|
|
44
47
|
return { label: item.kind === 'game' ? 'Play' : 'Use', href, needsAccount: false };
|
|
45
48
|
}
|
|
49
|
+
export function rendererFor(item) {
|
|
50
|
+
if (isStudioKind(item.kind))
|
|
51
|
+
return mediaFor(item) ? 'media' : 'art';
|
|
52
|
+
if (item.kind === 'site' || item.kind === 'game' || item.kind === 'tool')
|
|
53
|
+
return httpUrl(item.liveUrl) ? 'frame' : 'art';
|
|
54
|
+
return 'art';
|
|
55
|
+
}
|
|
56
|
+
const dateFormat = new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
|
|
57
|
+
function toTime(iso) {
|
|
58
|
+
if (!iso)
|
|
59
|
+
return null;
|
|
60
|
+
const t = Date.parse(iso);
|
|
61
|
+
return Number.isFinite(t) ? t : null;
|
|
62
|
+
}
|
|
63
|
+
/** "Oct 22, 2026" for an ISO timestamp, the same on every machine; null when absent or unparseable. */
|
|
64
|
+
export function formatDiscoverDate(iso) {
|
|
65
|
+
const t = toTime(iso);
|
|
66
|
+
return t === null ? null : dateFormat.format(t);
|
|
67
|
+
}
|
|
68
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
69
|
+
/** When the item was first published: `createdAt`, else the last publish. */
|
|
70
|
+
export const publishedDate = (item) => formatDiscoverDate(item.createdAt ?? item.updatedAt);
|
|
71
|
+
/** The update date, only when it is more than a day after the first publish. */
|
|
72
|
+
export function updatedDate(item) {
|
|
73
|
+
const created = toTime(item.createdAt);
|
|
74
|
+
const updated = toTime(item.updatedAt);
|
|
75
|
+
if (created === null || updated === null || updated - created <= DAY_MS)
|
|
76
|
+
return null;
|
|
77
|
+
return formatDiscoverDate(item.updatedAt);
|
|
78
|
+
}
|
|
79
|
+
/** Up to two initials for an avatar fallback: "Asha Rao" -> "AR", "asha" -> "A". */
|
|
80
|
+
export function creatorInitials(name) {
|
|
81
|
+
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
82
|
+
const letters = parts.length > 1 ? [parts[0], parts[parts.length - 1]] : parts;
|
|
83
|
+
return letters.map((p) => Array.from(p)[0]?.toUpperCase() ?? '').join('');
|
|
84
|
+
}
|
|
@@ -7,7 +7,7 @@ export interface DiscoverOption {
|
|
|
7
7
|
label: string;
|
|
8
8
|
href?: string;
|
|
9
9
|
}
|
|
10
|
-
export declare const GRID_CLASS = "grid grid-cols-[repeat(auto-fill,minmax(min(240px,100
|
|
10
|
+
export declare const GRID_CLASS = "grid grid-cols-[repeat(auto-fill,minmax(min(240px,calc((100%-1.25rem)/2)),1fr))] gap-x-5 gap-y-8";
|
|
11
11
|
export declare const LIST_CLASS = "flex flex-col divide-y divide-border rounded-xl border border-border";
|
|
12
12
|
/** Bleeds into the page's px-6 gutter (-mx-6 px-6), so the parent must pad px-6. */
|
|
13
13
|
export declare const SHELF_CLASS = "-mx-6 flex snap-x snap-mandatory scroll-px-6 gap-5 overflow-x-auto px-6 pb-3 [scrollbar-width:thin] *:w-64 *:shrink-0 *:snap-start";
|
package/dist/discover/layout.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// and the grid and list container classes the sections and the skeleton share.
|
|
4
4
|
// The classes are paw-enterprise's /discover markup, copied verbatim.
|
|
5
5
|
export const DISCOVER_VIEWS = ['grid', 'shelves', 'list'];
|
|
6
|
-
export const GRID_CLASS = 'grid grid-cols-[repeat(auto-fill,minmax(min(240px,100
|
|
6
|
+
export const GRID_CLASS = 'grid grid-cols-[repeat(auto-fill,minmax(min(240px,calc((100%-1.25rem)/2)),1fr))] gap-x-5 gap-y-8';
|
|
7
7
|
export const LIST_CLASS = 'flex flex-col divide-y divide-border rounded-xl border border-border';
|
|
8
8
|
/** Bleeds into the page's px-6 gutter (-mx-6 px-6), so the parent must pad px-6. */
|
|
9
9
|
export const SHELF_CLASS = '-mx-6 flex snap-x snap-mandatory scroll-px-6 gap-5 overflow-x-auto px-6 pb-3 [scrollbar-width:thin] *:w-64 *:shrink-0 *:snap-start';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { DiscoverKind } from './types.js';
|
|
2
|
+
export type DiscoverSectionKey = 'picks' | 'studio' | 'shop' | 'design' | 'everyone' | 'play' | 'site' | 'more';
|
|
3
|
+
export type DiscoverAudience = 'shop' | 'design' | 'everyone' | 'fun';
|
|
4
|
+
export interface DiscoverSectionCopy {
|
|
5
|
+
title: string;
|
|
6
|
+
blurb: string;
|
|
7
|
+
/** The "see all" label. */
|
|
8
|
+
more: string;
|
|
9
|
+
}
|
|
10
|
+
/** The type tabs, in display order. 'all' clears the kind filter. */
|
|
11
|
+
export declare const DISCOVER_TYPES: readonly {
|
|
12
|
+
value: DiscoverKind | 'all';
|
|
13
|
+
label: string;
|
|
14
|
+
}[];
|
|
15
|
+
/** Who an item is for: the audience chips and the publish fields share these labels. */
|
|
16
|
+
export declare const DISCOVER_AUDIENCES: readonly {
|
|
17
|
+
value: DiscoverAudience;
|
|
18
|
+
label: string;
|
|
19
|
+
}[];
|
|
20
|
+
/** Section copy with the product's name injected: "Paw" in the app, "Paw OS" on the website. */
|
|
21
|
+
export declare function discoverSections(brand: string): Record<DiscoverSectionKey, DiscoverSectionCopy>;
|
|
22
|
+
/** The minimum an item needs to be grouped. The app's and the website's item types both fit. */
|
|
23
|
+
export interface GroupableItem {
|
|
24
|
+
featured: boolean;
|
|
25
|
+
kind: string;
|
|
26
|
+
audiences?: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
/** Each item lands in its FIRST matching section, in input order; empty sections are dropped. */
|
|
29
|
+
export declare function groupSections<T extends GroupableItem>(items: readonly T[]): {
|
|
30
|
+
key: DiscoverSectionKey;
|
|
31
|
+
items: T[];
|
|
32
|
+
}[];
|
|
33
|
+
/** The filter a section's "All …" link sets, or null when no filter shows exactly that section. */
|
|
34
|
+
export declare function sectionFilter(key: DiscoverSectionKey): {
|
|
35
|
+
kind?: DiscoverKind;
|
|
36
|
+
audience?: DiscoverAudience;
|
|
37
|
+
} | null;
|
|
38
|
+
/** The heading over an item page's related items. Unknown kinds read "More like this". */
|
|
39
|
+
export declare const relatedHeading: (kind: string) => string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// discover/sections.ts — the catalogue copy and grouping every Discover page
|
|
2
|
+
// shares: the type tabs, the audience chips, the section titles and blurbs
|
|
3
|
+
// (with the product name passed in: the app says "Paw", the website "Paw OS"),
|
|
4
|
+
// which section each item lands in, which filter a section's "All …" link
|
|
5
|
+
// sets, and the heading over an item page's related items. Pure, so the app
|
|
6
|
+
// and the website render the same sections in the same order without copying
|
|
7
|
+
// the rules.
|
|
8
|
+
import { isStudioKind } from './item.js';
|
|
9
|
+
/** The type tabs, in display order. 'all' clears the kind filter. */
|
|
10
|
+
export const DISCOVER_TYPES = [
|
|
11
|
+
{ value: 'all', label: 'All' },
|
|
12
|
+
{ value: 'tool', label: 'Tools' },
|
|
13
|
+
{ value: 'game', label: 'Games' },
|
|
14
|
+
{ value: 'site', label: 'Sites' },
|
|
15
|
+
{ value: 'image', label: 'Images' },
|
|
16
|
+
{ value: 'video', label: 'Videos' },
|
|
17
|
+
{ value: 'music', label: 'Music' },
|
|
18
|
+
];
|
|
19
|
+
/** Who an item is for: the audience chips and the publish fields share these labels. */
|
|
20
|
+
export const DISCOVER_AUDIENCES = [
|
|
21
|
+
{ value: 'shop', label: 'For my shop' },
|
|
22
|
+
{ value: 'design', label: 'For designers' },
|
|
23
|
+
{ value: 'everyone', label: 'Everyday tools' },
|
|
24
|
+
{ value: 'fun', label: 'Just for fun' },
|
|
25
|
+
];
|
|
26
|
+
/** Section copy with the product's name injected: "Paw" in the app, "Paw OS" on the website. */
|
|
27
|
+
export function discoverSections(brand) {
|
|
28
|
+
return {
|
|
29
|
+
picks: { title: 'Staff picks', blurb: `Picked by us from what people make with ${brand}.`, more: 'All staff picks' },
|
|
30
|
+
studio: { title: 'Made in Paw Studio', blurb: 'Images, videos and songs. Open one with its recipe and make your own.', more: 'All Paw Studio work' },
|
|
31
|
+
shop: { title: 'For your shop', blurb: 'Print it, share it, put it on the counter.', more: 'All shop tools' },
|
|
32
|
+
design: { title: 'For designers', blurb: 'Small helpers for people who make things.', more: 'All design tools' },
|
|
33
|
+
everyone: { title: 'Everyday tools', blurb: 'Quick answers, no sign-up.', more: 'All everyday tools' },
|
|
34
|
+
play: { title: 'Play', blurb: `Small games, each made with ${brand} in an afternoon.`, more: 'All games' },
|
|
35
|
+
site: { title: 'Start from a site', blurb: "Live websites with a helper that answers customers. Claim one and it's yours.", more: 'All sites' },
|
|
36
|
+
more: { title: 'More to try', blurb: `Everything else made with ${brand}.`, more: 'See everything' },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const hasAudience = (it, a) => it.audiences?.includes(a) ?? false;
|
|
40
|
+
const SECTION_RULES = [
|
|
41
|
+
{ key: 'picks', match: (it) => it.featured },
|
|
42
|
+
// Before the audience rules, so a studio item never lands in a tools section.
|
|
43
|
+
{ key: 'studio', match: (it) => isStudioKind(it.kind) },
|
|
44
|
+
{ key: 'shop', match: (it) => it.kind === 'tool' && hasAudience(it, 'shop') },
|
|
45
|
+
{ key: 'design', match: (it) => hasAudience(it, 'design') },
|
|
46
|
+
{ key: 'everyone', match: (it) => hasAudience(it, 'everyone') },
|
|
47
|
+
{ key: 'play', match: (it) => it.kind === 'game' },
|
|
48
|
+
{ key: 'site', match: (it) => it.kind === 'site' },
|
|
49
|
+
// A tool aimed only at 'fun' (or no one) would otherwise vanish.
|
|
50
|
+
{ key: 'more', match: () => true },
|
|
51
|
+
];
|
|
52
|
+
/** Each item lands in its FIRST matching section, in input order; empty sections are dropped. */
|
|
53
|
+
export function groupSections(items) {
|
|
54
|
+
const out = SECTION_RULES.map((r) => ({ key: r.key, items: [] }));
|
|
55
|
+
for (const it of items)
|
|
56
|
+
out[SECTION_RULES.findIndex((r) => r.match(it))].items.push(it);
|
|
57
|
+
return out.filter((s) => s.items.length > 0);
|
|
58
|
+
}
|
|
59
|
+
/** The filter a section's "All …" link sets, or null when no filter shows exactly that section. */
|
|
60
|
+
export function sectionFilter(key) {
|
|
61
|
+
switch (key) {
|
|
62
|
+
case 'shop':
|
|
63
|
+
return { kind: 'tool', audience: 'shop' };
|
|
64
|
+
case 'design':
|
|
65
|
+
case 'everyone':
|
|
66
|
+
return { audience: key };
|
|
67
|
+
case 'play':
|
|
68
|
+
return { kind: 'game' };
|
|
69
|
+
case 'site':
|
|
70
|
+
return { kind: 'site' };
|
|
71
|
+
default:
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const RELATED = {
|
|
76
|
+
site: 'More sites',
|
|
77
|
+
game: 'More games',
|
|
78
|
+
tool: 'More tools',
|
|
79
|
+
image: 'More images',
|
|
80
|
+
video: 'More videos',
|
|
81
|
+
music: 'More music',
|
|
82
|
+
};
|
|
83
|
+
/** The heading over an item page's related items. Unknown kinds read "More like this". */
|
|
84
|
+
export const relatedHeading = (kind) => (Object.hasOwn(RELATED, kind) ? RELATED[kind] : 'More like this');
|
package/dist/discover/types.d.ts
CHANGED
|
@@ -20,4 +20,21 @@ export interface DiscoverItem {
|
|
|
20
20
|
/** Copies made; for a studio listing, times its recipe was opened. */
|
|
21
21
|
remixCount: number;
|
|
22
22
|
featured: boolean;
|
|
23
|
+
/** Who made it, when they opted in at publish. */
|
|
24
|
+
creator?: {
|
|
25
|
+
name: string;
|
|
26
|
+
handle?: string | null;
|
|
27
|
+
avatarUrl?: string | null;
|
|
28
|
+
} | null;
|
|
29
|
+
/** Free-form topic tags, drawn as chips on the detail view. */
|
|
30
|
+
tags?: string[];
|
|
31
|
+
/** ISO timestamp of the first publish. */
|
|
32
|
+
createdAt?: string | null;
|
|
33
|
+
/** ISO timestamp of the last publish. */
|
|
34
|
+
updatedAt?: string | null;
|
|
35
|
+
/** The listing this one was remixed from, by slug. */
|
|
36
|
+
remixedFrom?: {
|
|
37
|
+
slug: string;
|
|
38
|
+
title: string;
|
|
39
|
+
} | null;
|
|
23
40
|
}
|
package/dist/discover/types.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// discover/types.ts — the item shape the Discover card set renders. A flattened
|
|
2
2
|
// view of one public catalogue listing: enough to draw a tile, a row, or the
|
|
3
|
-
// detail
|
|
3
|
+
// detail view on the server, with no fetch and no store. Consumers map their
|
|
4
4
|
// wire format onto this; the components derive everything else (labels, the
|
|
5
|
-
// primary action, the studio flag) from these fields.
|
|
5
|
+
// primary action, the studio flag, the renderer) from these fields. The
|
|
6
|
+
// creator (with an optional avatar), tags, publish and update times and the
|
|
7
|
+
// remix source are optional: older listings and older consumers leave them
|
|
8
|
+
// out, and a card simply draws less.
|
|
6
9
|
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type CardLayout } from './whatsapp-card.js';
|
|
2
|
+
/** Paint the card at 300 dpi. Text that would run into the QR is narrowed to fit. */
|
|
3
|
+
export declare function paintCard(ctx: CanvasRenderingContext2D, layout: CardLayout): void;
|
|
4
|
+
export declare function cardPng(layout: CardLayout): Promise<Blob>;
|
|
5
|
+
export declare const cardSvgBlob: (layout: CardLayout) => Blob;
|
|
6
|
+
/** The default save: an anchor download of a blob URL. */
|
|
7
|
+
export declare function downloadBlob(blob: Blob, filename: string): void;
|
|
8
|
+
/** Print just the card, from a hidden iframe removed once printing is done. */
|
|
9
|
+
export declare function printCard(layout: CardLayout, title: string): void;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// discover/whatsapp-card-export.ts — getting the WhatsApp QR card out of the page,
|
|
2
|
+
// browser only (call these from event handlers, never at render): a 300 dpi PNG
|
|
3
|
+
// painted on a canvas from the same millimetre layout the SVG uses (./whatsapp-card
|
|
4
|
+
// cardLayout; the QR through Path2D, so it is the same path), the SVG as a blob,
|
|
5
|
+
// the default save (an anchor download of a blob URL, revoked a minute later since
|
|
6
|
+
// WebKit may read the blob after click() returns), and Print, which prints just the
|
|
7
|
+
// card on one 148 × 105 mm page from a hidden iframe so no host chrome or theme
|
|
8
|
+
// reaches the paper. A host with its own save path passes `ondownload` to the card.
|
|
9
|
+
import { CARD_EDGE, CARD_FONT, CARD_INK, CARD_PAPER, CARD_PX, PX_PER_MM, cardPrintHtml, cardSvg, } from './whatsapp-card.js';
|
|
10
|
+
/** Paint the card at 300 dpi. Text that would run into the QR is narrowed to fit. */
|
|
11
|
+
export function paintCard(ctx, layout) {
|
|
12
|
+
const s = PX_PER_MM;
|
|
13
|
+
ctx.fillStyle = CARD_PAPER;
|
|
14
|
+
ctx.fillRect(0, 0, CARD_PX.width, CARD_PX.height);
|
|
15
|
+
ctx.strokeStyle = CARD_EDGE;
|
|
16
|
+
ctx.lineWidth = 0.3 * s;
|
|
17
|
+
ctx.strokeRect(0.15 * s, 0.15 * s, (layout.width - 0.3) * s, (layout.height - 0.3) * s);
|
|
18
|
+
for (const t of layout.texts) {
|
|
19
|
+
ctx.font = `${t.weight} ${t.size * s}px ${CARD_FONT}`;
|
|
20
|
+
ctx.fillStyle = t.color;
|
|
21
|
+
ctx.textBaseline = 'alphabetic';
|
|
22
|
+
ctx.fillText(t.text, t.x * s, t.y * s, t.maxWidth * s);
|
|
23
|
+
}
|
|
24
|
+
const { qr } = layout;
|
|
25
|
+
ctx.save();
|
|
26
|
+
ctx.translate(qr.x * s, qr.y * s);
|
|
27
|
+
ctx.scale((qr.size * s) / qr.path.size, (qr.size * s) / qr.path.size);
|
|
28
|
+
ctx.fillStyle = CARD_INK;
|
|
29
|
+
ctx.fill(new Path2D(qr.path.d));
|
|
30
|
+
ctx.restore();
|
|
31
|
+
}
|
|
32
|
+
export async function cardPng(layout) {
|
|
33
|
+
const canvas = document.createElement('canvas');
|
|
34
|
+
canvas.width = CARD_PX.width;
|
|
35
|
+
canvas.height = CARD_PX.height;
|
|
36
|
+
const ctx = canvas.getContext('2d');
|
|
37
|
+
if (!ctx)
|
|
38
|
+
throw new Error('This browser cannot draw the card.');
|
|
39
|
+
paintCard(ctx, layout);
|
|
40
|
+
return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('The card could not be saved.'))), 'image/png'));
|
|
41
|
+
}
|
|
42
|
+
export const cardSvgBlob = (layout) => new Blob([cardSvg(layout)], { type: 'image/svg+xml' });
|
|
43
|
+
/** The default save: an anchor download of a blob URL. */
|
|
44
|
+
export function downloadBlob(blob, filename) {
|
|
45
|
+
const url = URL.createObjectURL(blob);
|
|
46
|
+
const a = document.createElement('a');
|
|
47
|
+
a.href = url;
|
|
48
|
+
a.download = filename;
|
|
49
|
+
document.body.appendChild(a);
|
|
50
|
+
a.click();
|
|
51
|
+
a.remove();
|
|
52
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
53
|
+
}
|
|
54
|
+
/** Print just the card, from a hidden iframe removed once printing is done. */
|
|
55
|
+
export function printCard(layout, title) {
|
|
56
|
+
const frame = document.createElement('iframe');
|
|
57
|
+
frame.setAttribute('aria-hidden', 'true');
|
|
58
|
+
frame.tabIndex = -1;
|
|
59
|
+
frame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden';
|
|
60
|
+
frame.onload = () => {
|
|
61
|
+
const win = frame.contentWindow;
|
|
62
|
+
if (!win)
|
|
63
|
+
return frame.remove();
|
|
64
|
+
win.addEventListener('afterprint', () => frame.remove(), { once: true });
|
|
65
|
+
win.focus();
|
|
66
|
+
win.print();
|
|
67
|
+
// Some engines never fire afterprint; don't leave the frame behind for good.
|
|
68
|
+
setTimeout(() => frame.remove(), 60_000);
|
|
69
|
+
};
|
|
70
|
+
frame.srcdoc = cardPrintHtml(layout, title);
|
|
71
|
+
document.body.appendChild(frame);
|
|
72
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type QrPath } from './whatsapp-qr.js';
|
|
2
|
+
export interface DialCode {
|
|
3
|
+
/** ISO 3166-1 alpha-2. */
|
|
4
|
+
iso: string;
|
|
5
|
+
name: string;
|
|
6
|
+
/** Calling code digits, no '+'. */
|
|
7
|
+
code: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const DIAL_CODES: readonly DialCode[];
|
|
10
|
+
export declare const DEFAULT_DIAL_ISO = "IN";
|
|
11
|
+
export declare const dialCodeFor: (iso: string) => DialCode;
|
|
12
|
+
/** The first browser language with a region we list (en-GB → GB), else India. */
|
|
13
|
+
export declare function defaultDialIso(languages: readonly string[] | undefined): string;
|
|
14
|
+
/** The listed country an international number's digits start with, longest code
|
|
15
|
+
* first; `current` wins among countries that share the matched code. */
|
|
16
|
+
export declare function matchDialCode(digits: string, current?: string): DialCode | null;
|
|
17
|
+
export declare const SHOP_MAX = 28;
|
|
18
|
+
export declare const MESSAGE_MAX = 200;
|
|
19
|
+
export declare const DEFAULT_SHOP = "Sharma Sweets";
|
|
20
|
+
export declare const DEFAULT_MESSAGE = "Hi! I'd like to place an order.";
|
|
21
|
+
/** The example number the server renders, so a page with JS off shows a real card. */
|
|
22
|
+
export declare const EXAMPLE_PHONE = "98765 43210";
|
|
23
|
+
/** E.164 allows at most 15 digits; below 8 no real number is complete. */
|
|
24
|
+
export declare const E164_MIN = 8;
|
|
25
|
+
export declare const E164_MAX = 15;
|
|
26
|
+
/** The national part as digits: separators dropped, trunk zeros dropped. */
|
|
27
|
+
export declare const nationalDigits: (raw: string) => string;
|
|
28
|
+
/** A pasted international number ("+44 7700 900123", "0044 7700 900123") split into
|
|
29
|
+
* the listed country it starts with and the rest. Null for a national number, or an
|
|
30
|
+
* international one whose code we don't list. */
|
|
31
|
+
export declare function splitInternational(raw: string, currentIso?: string): {
|
|
32
|
+
iso: string;
|
|
33
|
+
national: string;
|
|
34
|
+
} | null;
|
|
35
|
+
export type PhoneCheck = {
|
|
36
|
+
ok: true;
|
|
37
|
+
digits: string;
|
|
38
|
+
line: string;
|
|
39
|
+
}
|
|
40
|
+
/** `error` is null while the field is empty: nothing to complain about yet. */
|
|
41
|
+
| {
|
|
42
|
+
ok: false;
|
|
43
|
+
error: string | null;
|
|
44
|
+
};
|
|
45
|
+
/** "+91 98765 43210": ten-digit numbers split 5 + 5, others in fours from the end. */
|
|
46
|
+
export declare function formatLine(code: string, national: string): string;
|
|
47
|
+
export declare function checkPhone(code: string, raw: string): PhoneCheck;
|
|
48
|
+
/** https://wa.me/<digits>, with ?text= when there is a message. */
|
|
49
|
+
export declare function waLink(digits: string, message: string): string;
|
|
50
|
+
/** `<shop>-whatsapp-qr.<ext>`, slugified; `whatsapp-qr.<ext>` for a name with no letters. */
|
|
51
|
+
export declare function cardFilename(shop: string, ext: 'png' | 'svg'): string;
|
|
52
|
+
export declare const CARD_MM: {
|
|
53
|
+
readonly width: 148;
|
|
54
|
+
readonly height: 105;
|
|
55
|
+
};
|
|
56
|
+
export declare const PNG_DPI = 300;
|
|
57
|
+
/** Pixels per millimetre at the PNG's resolution. */
|
|
58
|
+
export declare const PX_PER_MM: number;
|
|
59
|
+
/** 148 × 105 mm at 300 dpi. */
|
|
60
|
+
export declare const CARD_PX: {
|
|
61
|
+
readonly width: number;
|
|
62
|
+
readonly height: number;
|
|
63
|
+
};
|
|
64
|
+
export declare const CARD_INK = "#111111";
|
|
65
|
+
export declare const CARD_PAPER = "#ffffff";
|
|
66
|
+
export declare const CARD_MUTED = "#555555";
|
|
67
|
+
export declare const CARD_EDGE = "#d4d4d4";
|
|
68
|
+
export declare const CARD_FONT = "Helvetica, Arial, sans-serif";
|
|
69
|
+
export declare const CARD_CTA = "Scan to order on WhatsApp \u2192";
|
|
70
|
+
export declare const DEFAULT_BRAND = "Paw";
|
|
71
|
+
/** The small print at the card's foot, naming the host's product. */
|
|
72
|
+
export declare const cardFooter: (brand?: string) => string;
|
|
73
|
+
export interface CardText {
|
|
74
|
+
text: string;
|
|
75
|
+
/** Left edge and baseline, in mm. */
|
|
76
|
+
x: number;
|
|
77
|
+
y: number;
|
|
78
|
+
/** Font size in mm. */
|
|
79
|
+
size: number;
|
|
80
|
+
weight: 400 | 700;
|
|
81
|
+
color: string;
|
|
82
|
+
/** Widest the text may run, in mm (the column beside the QR). */
|
|
83
|
+
maxWidth: number;
|
|
84
|
+
}
|
|
85
|
+
export interface CardLayout {
|
|
86
|
+
width: number;
|
|
87
|
+
height: number;
|
|
88
|
+
texts: CardText[];
|
|
89
|
+
/** QR square: top-left and side, in mm. */
|
|
90
|
+
qr: {
|
|
91
|
+
x: number;
|
|
92
|
+
y: number;
|
|
93
|
+
size: number;
|
|
94
|
+
path: QrPath;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export declare function cardLayout(input: {
|
|
98
|
+
shop: string;
|
|
99
|
+
line: string;
|
|
100
|
+
link: string;
|
|
101
|
+
brand?: string;
|
|
102
|
+
}): CardLayout;
|
|
103
|
+
/** The card as a standalone SVG document, sized in millimetres for print. */
|
|
104
|
+
export declare function cardSvg(layout: CardLayout): string;
|
|
105
|
+
/** The print document: one card on a 148 × 105 mm page, nothing else. */
|
|
106
|
+
export declare function cardPrintHtml(layout: CardLayout, title: string): string;
|