@vibes.diy/prompts 14.3.64 → 14.3.65
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/llms/index.d.ts +29 -1
- package/llms/index.js +84 -0
- package/llms/index.js.map +1 -1
- package/llms/pouf-blog.d.ts +2 -0
- package/llms/pouf-blog.js +6 -0
- package/llms/pouf-blog.js.map +1 -0
- package/llms/pouf-blog.md +305 -0
- package/llms/pouf-booking.d.ts +2 -0
- package/llms/pouf-booking.js +6 -0
- package/llms/pouf-booking.js.map +1 -0
- package/llms/pouf-booking.md +397 -0
- package/llms/pouf-calendar.d.ts +2 -0
- package/llms/pouf-calendar.js +6 -0
- package/llms/pouf-calendar.js.map +1 -0
- package/llms/pouf-calendar.md +352 -0
- package/llms/pouf-chat.d.ts +2 -0
- package/llms/pouf-chat.js +6 -0
- package/llms/pouf-chat.js.map +1 -0
- package/llms/pouf-chat.md +220 -0
- package/llms/pouf-contact.d.ts +2 -0
- package/llms/pouf-contact.js +6 -0
- package/llms/pouf-contact.js.map +1 -0
- package/llms/pouf-contact.md +231 -0
- package/llms/pouf-course.d.ts +2 -0
- package/llms/pouf-course.js +6 -0
- package/llms/pouf-course.js.map +1 -0
- package/llms/pouf-course.md +299 -0
- package/llms/pouf-crm.d.ts +2 -0
- package/llms/pouf-crm.js +6 -0
- package/llms/pouf-crm.js.map +1 -0
- package/llms/pouf-crm.md +330 -0
- package/llms/pouf-dashboard.d.ts +2 -0
- package/llms/pouf-dashboard.js +6 -0
- package/llms/pouf-dashboard.js.map +1 -0
- package/llms/pouf-dashboard.md +269 -0
- package/llms/pouf-editorial.d.ts +2 -0
- package/llms/pouf-editorial.js +6 -0
- package/llms/pouf-editorial.js.map +1 -0
- package/llms/pouf-editorial.md +247 -0
- package/llms/pouf-event.d.ts +2 -0
- package/llms/pouf-event.js +6 -0
- package/llms/pouf-event.js.map +1 -0
- package/llms/pouf-event.md +237 -0
- package/llms/pouf-feed.d.ts +2 -0
- package/llms/pouf-feed.js +6 -0
- package/llms/pouf-feed.js.map +1 -0
- package/llms/pouf-feed.md +249 -0
- package/llms/pouf-game.d.ts +2 -0
- package/llms/pouf-game.js +6 -0
- package/llms/pouf-game.js.map +1 -0
- package/llms/pouf-game.md +273 -0
- package/llms/pouf-inbox.d.ts +2 -0
- package/llms/pouf-inbox.js +6 -0
- package/llms/pouf-inbox.js.map +1 -0
- package/llms/pouf-inbox.md +317 -0
- package/llms/pouf-inventory.d.ts +2 -0
- package/llms/pouf-inventory.js +6 -0
- package/llms/pouf-inventory.js.map +1 -0
- package/llms/pouf-inventory.md +345 -0
- package/llms/pouf-kanban.d.ts +2 -0
- package/llms/pouf-kanban.js +6 -0
- package/llms/pouf-kanban.js.map +1 -0
- package/llms/pouf-kanban.md +235 -0
- package/llms/pouf-landing.d.ts +2 -0
- package/llms/pouf-landing.js +6 -0
- package/llms/pouf-landing.js.map +1 -0
- package/llms/pouf-landing.md +222 -0
- package/llms/pouf-login.d.ts +2 -0
- package/llms/pouf-login.js +6 -0
- package/llms/pouf-login.js.map +1 -0
- package/llms/pouf-login.md +162 -0
- package/llms/pouf-music.d.ts +2 -0
- package/llms/pouf-music.js +6 -0
- package/llms/pouf-music.js.map +1 -0
- package/llms/pouf-music.md +190 -0
- package/llms/pouf-onboarding.d.ts +2 -0
- package/llms/pouf-onboarding.js +6 -0
- package/llms/pouf-onboarding.js.map +1 -0
- package/llms/pouf-onboarding.md +227 -0
- package/llms/pouf-pricing.d.ts +2 -0
- package/llms/pouf-pricing.js +6 -0
- package/llms/pouf-pricing.js.map +1 -0
- package/llms/pouf-pricing.md +193 -0
- package/llms/pouf-profile.d.ts +2 -0
- package/llms/pouf-profile.js +6 -0
- package/llms/pouf-profile.js.map +1 -0
- package/llms/pouf-profile.md +177 -0
- package/llms/pouf-quiz.d.ts +2 -0
- package/llms/pouf-quiz.js +6 -0
- package/llms/pouf-quiz.js.map +1 -0
- package/llms/pouf-quiz.md +267 -0
- package/llms/pouf-settings.d.ts +2 -0
- package/llms/pouf-settings.js +6 -0
- package/llms/pouf-settings.js.map +1 -0
- package/llms/pouf-settings.md +220 -0
- package/llms/pouf-storefront.d.ts +2 -0
- package/llms/pouf-storefront.js +6 -0
- package/llms/pouf-storefront.js.map +1 -0
- package/llms/pouf-storefront.md +293 -0
- package/llms/pouf-support.d.ts +2 -0
- package/llms/pouf-support.js +6 -0
- package/llms/pouf-support.js.map +1 -0
- package/llms/pouf-support.md +322 -0
- package/llms/pouf-testimonials.d.ts +2 -0
- package/llms/pouf-testimonials.js +6 -0
- package/llms/pouf-testimonials.js.map +1 -0
- package/llms/pouf-testimonials.md +156 -0
- package/llms/pouf-todo.d.ts +2 -0
- package/llms/pouf-todo.js +6 -0
- package/llms/pouf-todo.js.map +1 -0
- package/llms/pouf-todo.md +179 -0
- package/llms/pouf-weather.d.ts +2 -0
- package/llms/pouf-weather.js +6 -0
- package/llms/pouf-weather.js.map +1 -0
- package/llms/pouf-weather.md +186 -0
- package/package.json +4 -4
- package/prompts.js +2 -2
- package/prompts.js.map +1 -1
- package/themes/colorsets.d.ts +1 -1
- package/themes/colorsets.js +63 -1
- package/themes/colorsets.js.map +1 -1
- package/themes/index.d.ts +1 -0
- package/themes/index.js +1 -0
- package/themes/index.js.map +1 -1
- package/themes/looks/index.js +6 -2
- package/themes/looks/index.js.map +1 -1
- package/themes/looks/pouf/brief.md +144 -0
- package/themes/looks/pouf/catalog.generated.d.ts +16 -0
- package/themes/looks/pouf/catalog.generated.js +764 -0
- package/themes/looks/pouf/catalog.generated.js.map +1 -0
- package/themes/looks/pouf/variety.d.ts +4 -0
- package/themes/looks/pouf/variety.js +36 -0
- package/themes/looks/pouf/variety.js.map +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pouf-music.js","sourceRoot":"","sources":["../../jsr/llms/pouf-music.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,eAAe,GAAc;IACxC,IAAI,EAAE,YAAY;IAClB,KAAK,EAAE,kBAAkB;IACzB,WAAW,EAAE,yDAAyD;CACvE,CAAC"}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Pouf Music
|
|
2
|
+
|
|
3
|
+
A player screen for a short list of tracks: a band's demos, a choir's rehearsal parts, a podcast's episodes, a class's listening list. Reach for it whenever the app's core is picking something from a queue and moving through it with play, skip and a scrubber. The track list stays inline in the code and playback lives in the page; each like is its own document per person and track, so a visitor's favourites are still marked on the next visit and on every device, and the like count is real.
|
|
4
|
+
|
|
5
|
+
Built from @vibes.diy/pouf components; persisted state lives in Fireproof. Every write surface is gated on `useVibe(dbName).can`.
|
|
6
|
+
|
|
7
|
+
## The block
|
|
8
|
+
|
|
9
|
+
One view with one job: choose a track and move through it. A `Card` holds the player: a decorative `orange` `Blob` as the cover, a `Badge` for the transport's state (`up` playing, `idle` paused), the title as the `Heading` with the artist and like count in muted `Text`, a quiet heart `IconButton` to like the track, a `Slider` scrubber with numeric elapsed and total times, and the transport row of quiet `IconButton`s (previous, play/pause, next). The scrubber's filled range is the view's one lead mark: `Slider` always paints its range in the lead colour and takes no `tone`, so every button beside it stays quiet. Nothing plays until the person presses play, and the example carries no audio of its own: the track list is placeholder metadata and the scrubber is driven by a timer, ready to be wired to the app's own files. Under the player an `Eyebrow` heads the queue, one clickable `RowCard` per track with its title and artist in `Text`, a heart `Icon` on the visitor's liked tracks and the length in numeric muted `Text`. Liking waits on its `can` verdict from `useVibe("music")`, the database the likes live in: a `Skeleton` inside an `aria-busy` wrapper while it resolves, the heart wired to `useViewer().requestLogin()` with the label `Sign in to like` for a visitor who has not signed in, and a sentence in the app's own words when likes are closed; unliking waits on `can.delete` of the visitor's own like.
|
|
10
|
+
|
|
11
|
+
### `App.jsx`
|
|
12
|
+
```jsx
|
|
13
|
+
import React, { useEffect, useState } from "react";
|
|
14
|
+
import { useFireproof } from "use-fireproof";
|
|
15
|
+
import { useViewer, useVibe } from "use-vibes";
|
|
16
|
+
import { Badge, Blob, Card, Eyebrow, Heading, Icon, IconButton, Row, RowCard, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
|
|
17
|
+
import { Slider } from "@vibes.diy/pouf/interactive";
|
|
18
|
+
|
|
19
|
+
// Placeholder metadata: swap in the app's own tracks. No audio ships with this example.
|
|
20
|
+
const QUEUE = [
|
|
21
|
+
{ id: "t1", title: "Warm-up in C", artist: "Altos", duration: 204 },
|
|
22
|
+
{ id: "t2", title: "Harbour Song", artist: "Full choir", duration: 241 },
|
|
23
|
+
{ id: "t3", title: "Round for Four", artist: "Tenors", duration: 167 },
|
|
24
|
+
{ id: "t4", title: "Evening Hymn", artist: "Sopranos", duration: 238 },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function clock(seconds) {
|
|
28
|
+
const m = Math.floor(seconds / 60);
|
|
29
|
+
const s = Math.floor(seconds % 60);
|
|
30
|
+
return `${m}:${String(s).padStart(2, "0")}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function App() {
|
|
34
|
+
const { database, useLiveQuery } = useFireproof("music");
|
|
35
|
+
const { docs: likes } = useLiveQuery("type", { key: "like" });
|
|
36
|
+
const { can, me } = useVibe("music");
|
|
37
|
+
const { requestLogin } = useViewer();
|
|
38
|
+
|
|
39
|
+
const [index, setIndex] = useState(0);
|
|
40
|
+
const [elapsed, setElapsed] = useState(0);
|
|
41
|
+
// Paused on arrival: nothing plays until the person presses play.
|
|
42
|
+
const [playing, setPlaying] = useState(false);
|
|
43
|
+
const [liking, setLiking] = useState(false);
|
|
44
|
+
|
|
45
|
+
const track = QUEUE[index];
|
|
46
|
+
const trackLikes = likes.filter((l) => l.trackId === track.id);
|
|
47
|
+
const myLikes = new Set(me ? likes.filter((l) => l.authorHandle === me.userHandle).map((l) => l.trackId) : []);
|
|
48
|
+
const mine = me ? trackLikes.find((l) => l.authorHandle === me.userHandle) : undefined;
|
|
49
|
+
// The like this visitor would write: a deterministic id, so liking twice lands on one document.
|
|
50
|
+
const likeDraft = { _id: `like:${track.id}:${me?.userHandle}`, type: "like", trackId: track.id, authorHandle: me?.userHandle };
|
|
51
|
+
const verdict = mine ? can.delete(mine) : can.create(likeDraft);
|
|
52
|
+
|
|
53
|
+
function goTo(next) {
|
|
54
|
+
setIndex(((next % QUEUE.length) + QUEUE.length) % QUEUE.length);
|
|
55
|
+
setElapsed(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Moves the scrubber once a second while playing.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!playing) return undefined;
|
|
61
|
+
const id = setInterval(() => setElapsed((s) => Math.min(track.duration, s + 1)), 1000);
|
|
62
|
+
return () => clearInterval(id);
|
|
63
|
+
}, [playing, track.duration]);
|
|
64
|
+
|
|
65
|
+
// Reaching the end moves on to the next track; kept apart so the timer only ever adds a second.
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (elapsed < track.duration) return;
|
|
68
|
+
setIndex((i) => (i + 1) % QUEUE.length);
|
|
69
|
+
setElapsed(0);
|
|
70
|
+
}, [elapsed, track.duration]);
|
|
71
|
+
|
|
72
|
+
async function toggleLike() {
|
|
73
|
+
setLiking(true);
|
|
74
|
+
try {
|
|
75
|
+
if (mine) await database.del(mine._id);
|
|
76
|
+
else await database.put({ ...likeDraft, createdAt: Date.now() });
|
|
77
|
+
} finally {
|
|
78
|
+
setLiking(false);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let like;
|
|
83
|
+
if (verdict.pending)
|
|
84
|
+
like = (
|
|
85
|
+
<div aria-busy="true">
|
|
86
|
+
<Skeleton variant="text" />
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
else if (!me) like = <IconButton icon={<Icon name="heart" size="sm" />} label="Sign in to like" onClick={requestLogin} />;
|
|
90
|
+
else if (!verdict.ok)
|
|
91
|
+
like = (
|
|
92
|
+
<Text size="sm" muted>
|
|
93
|
+
Likes are resting for now.
|
|
94
|
+
</Text>
|
|
95
|
+
);
|
|
96
|
+
else
|
|
97
|
+
like = (
|
|
98
|
+
<IconButton
|
|
99
|
+
icon={<Icon name={mine ? "heart-filled" : "heart"} size="sm" />}
|
|
100
|
+
label={mine ? `Unlike ${track.title}` : `Like ${track.title}`}
|
|
101
|
+
aria-pressed={Boolean(mine)}
|
|
102
|
+
loading={liking}
|
|
103
|
+
onClick={toggleLike}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<main className="mx-auto max-w-md px-4 py-8">
|
|
109
|
+
<Stack gap={4}>
|
|
110
|
+
<Card>
|
|
111
|
+
<Stack gap={5}>
|
|
112
|
+
<Row justify="between" align="top">
|
|
113
|
+
<Blob icon="music" tone="orange" size="lg" />
|
|
114
|
+
<Badge tone={playing ? "up" : "idle"}>{playing ? "Playing" : "Paused"}</Badge>
|
|
115
|
+
</Row>
|
|
116
|
+
<Row justify="between" align="top" wrap={false}>
|
|
117
|
+
<Stack gap={1}>
|
|
118
|
+
<Heading level={1}>{track.title}</Heading>
|
|
119
|
+
<Text muted>
|
|
120
|
+
{track.artist} · {trackLikes.length === 1 ? "1 like" : `${trackLikes.length} likes`}
|
|
121
|
+
</Text>
|
|
122
|
+
</Stack>
|
|
123
|
+
{like}
|
|
124
|
+
</Row>
|
|
125
|
+
<Stack gap={2}>
|
|
126
|
+
<Slider value={[elapsed]} onChange={([s]) => setElapsed(s)} max={track.duration} label="Seek" />
|
|
127
|
+
<Row justify="between">
|
|
128
|
+
<Text size="sm" muted num>
|
|
129
|
+
{clock(elapsed)}
|
|
130
|
+
</Text>
|
|
131
|
+
<Text size="sm" muted num>
|
|
132
|
+
{clock(track.duration)}
|
|
133
|
+
</Text>
|
|
134
|
+
</Row>
|
|
135
|
+
</Stack>
|
|
136
|
+
<Row justify="center" gap={3}>
|
|
137
|
+
<IconButton size="lg" icon={<Icon name="rewind" size="md" />} label="Previous track" onClick={() => goTo(index - 1)} />
|
|
138
|
+
<IconButton
|
|
139
|
+
size="lg"
|
|
140
|
+
icon={<Icon name={playing ? "pause" : "play"} size="md" />}
|
|
141
|
+
label={playing ? "Pause" : "Play"}
|
|
142
|
+
onClick={() => setPlaying((p) => !p)}
|
|
143
|
+
/>
|
|
144
|
+
<IconButton size="lg" icon={<Icon name="forward" size="md" />} label="Next track" onClick={() => goTo(index + 1)} />
|
|
145
|
+
</Row>
|
|
146
|
+
</Stack>
|
|
147
|
+
</Card>
|
|
148
|
+
|
|
149
|
+
<Stack gap={2}>
|
|
150
|
+
<Eyebrow>Up next</Eyebrow>
|
|
151
|
+
{QUEUE.map((t, i) => (
|
|
152
|
+
<RowCard
|
|
153
|
+
key={t.id}
|
|
154
|
+
onClick={() => {
|
|
155
|
+
goTo(i);
|
|
156
|
+
setPlaying(true);
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
<Row gap={3} wrap={false}>
|
|
160
|
+
<Icon name={i === index ? "music" : "play"} size="sm" />
|
|
161
|
+
<Stack gap={1}>
|
|
162
|
+
<Text>{t.title}</Text>
|
|
163
|
+
<Text size="sm" muted>
|
|
164
|
+
{i === index ? `Now · ${t.artist}` : t.artist}
|
|
165
|
+
</Text>
|
|
166
|
+
</Stack>
|
|
167
|
+
<Spacer />
|
|
168
|
+
{myLikes.has(t.id) && <Icon name="heart-filled" size="sm" />}
|
|
169
|
+
<Text size="sm" muted num>
|
|
170
|
+
{clock(t.duration)}
|
|
171
|
+
</Text>
|
|
172
|
+
</Row>
|
|
173
|
+
</RowCard>
|
|
174
|
+
))}
|
|
175
|
+
</Stack>
|
|
176
|
+
</Stack>
|
|
177
|
+
</main>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Adapting
|
|
183
|
+
|
|
184
|
+
- Swap `QUEUE` for the app's own tracks. Real playback is an `<audio>` element whose `src` is the app's own file (a `_files` attachment on a `track` document, read with `useLiveQuery`): call `play()` and `pause()` from the play button, drive `elapsed` from its `timeupdate` event and seek by setting `currentTime` from the `Slider`. Start paused, and never point at media the app has no right to play.
|
|
185
|
+
- Keep one like document per person and track with a deterministic `_id`; a like counter on the track loses likes when two people tap at once, and the count is always the number of like documents.
|
|
186
|
+
- A "Liked" view is a `Segmented` (`tone="info"`) over the queue filtered by the visitor's own likes, not a second database.
|
|
187
|
+
- `Slider` has no `tone` prop and always fills its range with the lead colour, so a view with a scrubber already has its lead: keep play/pause, skip and the heart quiet `IconButton`s, each with a `label` that names its action, and add no second slider (a volume control belongs with real audio, in its own `Card` or `Sheet`).
|
|
188
|
+
- A new write surface gets its own verdict: `can.create(draft)` with the fields the write stamps, `can.edit(doc)` or `can.delete(doc)` on the document it changes, always on `useVibe` of the database it writes.
|
|
189
|
+
|
|
190
|
+
Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/music.json
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pouf-onboarding.js","sourceRoot":"","sources":["../../jsr/llms/pouf-onboarding.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,oBAAoB,GAAc;IAC7C,IAAI,EAAE,iBAAiB;IACvB,KAAK,EAAE,uBAAuB;IAC9B,WAAW,EAAE,kEAAkE;CAChF,CAAC"}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Pouf Onboarding
|
|
2
|
+
|
|
3
|
+
A short setup wizard that walks a newcomer through a few questions, one at a time: a study group's "who are you and which team", a freelance tool's business name and plan, a club's first-visit questions. Reach for it whenever the app needs a handful of answers before it is useful and they read better as steps than as one long form. The answers persist as the person's own document, so a finished setup stays finished and can be revisited and changed later.
|
|
4
|
+
|
|
5
|
+
Built from @vibes.diy/pouf components; persisted state lives in Fireproof. Every write surface is gated on `useVibe(dbName).can`.
|
|
6
|
+
|
|
7
|
+
## The block
|
|
8
|
+
|
|
9
|
+
One `Card` with one job per step. An `Eyebrow` names the step ("Step 2 of 3 · Workspace") above a `Progress` bar toned `up`; each step is a `Heading` and one control in a `Field` (an `Input`, or a `RadioGroup` for the plan), checked in plain state when the person moves on, with the error on that step's own `Field` and its control `invalid`. A quiet `Back` and a `Continue` lead sit at the foot of every step; on the last step the lead becomes `Finish` and shows `loading` while the answers are saved. Answers live in one state object that outlives each step, so paging back and forth never loses what was typed. Finishing writes the person's setup as one document with a deterministic `_id` (`setup:<handle>`), and a finished setup opens on a recap with a quiet `Change answers` button; the whole card waits behind a `Skeleton` until identity resolves, so a returning member never sees step one flash first. The finish waits on its `can` verdict from `useVibe("setup")`: a `Skeleton` inside an `aria-busy` wrapper while it resolves, a sentence in the app's own words when saving is closed, and for a visitor who has not signed in a `Sign in to finish` lead wired to `useViewer().requestLogin()`, with every answer they typed kept in place.
|
|
10
|
+
|
|
11
|
+
### `App.jsx`
|
|
12
|
+
```jsx
|
|
13
|
+
import React, { useState } from "react";
|
|
14
|
+
import { useFireproof } from "use-fireproof";
|
|
15
|
+
import { useViewer, useVibe } from "use-vibes";
|
|
16
|
+
import { Button, Card, Eyebrow, Field, Heading, Input, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
|
|
17
|
+
import { Progress, RadioGroup, Toaster, toast } from "@vibes.diy/pouf/interactive";
|
|
18
|
+
|
|
19
|
+
const STEPS = ["About you", "Workspace", "How you'll use it"];
|
|
20
|
+
const PLANS = [
|
|
21
|
+
{ value: "solo", label: "Just me" },
|
|
22
|
+
{ value: "team", label: "With a team" },
|
|
23
|
+
{ value: "org", label: "Across a whole organisation" },
|
|
24
|
+
];
|
|
25
|
+
const BLANK = { name: "", workspace: "", plan: "solo" };
|
|
26
|
+
|
|
27
|
+
/** The one check each step makes before moving on. */
|
|
28
|
+
function stepError(step, answers) {
|
|
29
|
+
if (step === 0 && answers.name.trim() === "") return { name: "Tell us what to call you." };
|
|
30
|
+
if (step === 1 && answers.workspace.trim() === "") return { workspace: "Pick a name for your workspace." };
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Recap({ setup, onChange }) {
|
|
35
|
+
return (
|
|
36
|
+
<Stack gap={4}>
|
|
37
|
+
<Eyebrow>All set</Eyebrow>
|
|
38
|
+
<Heading level={1}>You're all set, {setup.name}</Heading>
|
|
39
|
+
<Text muted>Here's what you told us.</Text>
|
|
40
|
+
<Stack gap={2}>
|
|
41
|
+
<Row justify="between" wrap={false}>
|
|
42
|
+
<Text size="sm" muted>
|
|
43
|
+
Workspace
|
|
44
|
+
</Text>
|
|
45
|
+
<Text size="sm">{setup.workspace}</Text>
|
|
46
|
+
</Row>
|
|
47
|
+
<Row justify="between" wrap={false}>
|
|
48
|
+
<Text size="sm" muted>
|
|
49
|
+
Using it
|
|
50
|
+
</Text>
|
|
51
|
+
<Text size="sm">{PLANS.find((p) => p.value === setup.plan)?.label}</Text>
|
|
52
|
+
</Row>
|
|
53
|
+
</Stack>
|
|
54
|
+
<Row justify="end">
|
|
55
|
+
<Button variant="quiet" onClick={onChange}>
|
|
56
|
+
Change answers
|
|
57
|
+
</Button>
|
|
58
|
+
</Row>
|
|
59
|
+
</Stack>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default function App() {
|
|
64
|
+
const { database, useLiveQuery } = useFireproof("setup");
|
|
65
|
+
const { docs } = useLiveQuery("type", { key: "setup" });
|
|
66
|
+
const { can, me, ready } = useVibe("setup");
|
|
67
|
+
const { requestLogin } = useViewer();
|
|
68
|
+
const saved = me ? docs.find((d) => d._id === `setup:${me.userHandle}`) : undefined;
|
|
69
|
+
|
|
70
|
+
const [step, setStep] = useState(0);
|
|
71
|
+
const [editing, setEditing] = useState(false);
|
|
72
|
+
const [answers, setAnswers] = useState(BLANK);
|
|
73
|
+
const [errors, setErrors] = useState({});
|
|
74
|
+
const [saving, setSaving] = useState(false);
|
|
75
|
+
|
|
76
|
+
// The document Finish writes: one per person, so changing answers lands on the same one.
|
|
77
|
+
const draft = { _id: `setup:${me?.userHandle}`, type: "setup", authorHandle: me?.userHandle };
|
|
78
|
+
const verdict = saved ? can.edit(saved) : can.create(draft);
|
|
79
|
+
const last = step === STEPS.length - 1;
|
|
80
|
+
|
|
81
|
+
const set = (key) => (value) => {
|
|
82
|
+
setAnswers((a) => ({ ...a, [key]: value }));
|
|
83
|
+
if (errors[key]) setErrors((e) => ({ ...e, [key]: undefined }));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function changeAnswers() {
|
|
87
|
+
setAnswers({ name: saved.name, workspace: saved.workspace, plan: saved.plan });
|
|
88
|
+
setStep(0);
|
|
89
|
+
setEditing(true);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function next(e) {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
const found = stepError(step, answers);
|
|
95
|
+
setErrors(found);
|
|
96
|
+
if (Object.keys(found).length > 0) return;
|
|
97
|
+
if (!last) {
|
|
98
|
+
setStep((s) => s + 1);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
setSaving(true);
|
|
102
|
+
try {
|
|
103
|
+
await database.put({ ...saved, ...draft, name: answers.name.trim(), workspace: answers.workspace.trim(), plan: answers.plan, finishedAt: Date.now() });
|
|
104
|
+
setEditing(false);
|
|
105
|
+
toast.success("Setup saved");
|
|
106
|
+
} finally {
|
|
107
|
+
setSaving(false);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The finish button: still checking, closed, signed out, or open.
|
|
112
|
+
let finish;
|
|
113
|
+
if (!ready || verdict.pending)
|
|
114
|
+
finish = (
|
|
115
|
+
<div aria-busy="true">
|
|
116
|
+
<Skeleton variant="row" />
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
else if (!me)
|
|
120
|
+
// Signing in keeps every answer: they live in this component's state.
|
|
121
|
+
finish = <Button onClick={requestLogin}>Sign in to finish</Button>;
|
|
122
|
+
else if (!verdict.ok) finish = <Text muted>Setup is saved for invited members only.</Text>;
|
|
123
|
+
else
|
|
124
|
+
finish = (
|
|
125
|
+
<Button type="submit" loading={saving}>
|
|
126
|
+
Finish
|
|
127
|
+
</Button>
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<main className="mx-auto max-w-lg px-4 py-10">
|
|
132
|
+
<Card>
|
|
133
|
+
{!ready ? (
|
|
134
|
+
// Held until identity resolves, so a returning member sees the recap, not step one.
|
|
135
|
+
<div aria-busy="true">
|
|
136
|
+
<Skeleton variant="card" />
|
|
137
|
+
</div>
|
|
138
|
+
) : saved && !editing ? (
|
|
139
|
+
<Recap setup={saved} onChange={changeAnswers} />
|
|
140
|
+
) : (
|
|
141
|
+
<form onSubmit={next} noValidate>
|
|
142
|
+
<Stack gap={5}>
|
|
143
|
+
<Stack gap={2}>
|
|
144
|
+
<Eyebrow>
|
|
145
|
+
Step {step + 1} of {STEPS.length} · {STEPS[step]}
|
|
146
|
+
</Eyebrow>
|
|
147
|
+
<Progress value={step + 1} max={STEPS.length} tone="up" label="Setup progress" />
|
|
148
|
+
</Stack>
|
|
149
|
+
|
|
150
|
+
{step === 0 && (
|
|
151
|
+
<Stack gap={4}>
|
|
152
|
+
<Heading level={1}>Welcome aboard</Heading>
|
|
153
|
+
<Field label="Your name" error={errors.name}>
|
|
154
|
+
{(id, describedBy) => (
|
|
155
|
+
<Input
|
|
156
|
+
id={id}
|
|
157
|
+
describedBy={describedBy}
|
|
158
|
+
value={answers.name}
|
|
159
|
+
onChange={set("name")}
|
|
160
|
+
autoComplete="name"
|
|
161
|
+
placeholder="Ada"
|
|
162
|
+
invalid={errors.name !== undefined}
|
|
163
|
+
/>
|
|
164
|
+
)}
|
|
165
|
+
</Field>
|
|
166
|
+
</Stack>
|
|
167
|
+
)}
|
|
168
|
+
{step === 1 && (
|
|
169
|
+
<Stack gap={4}>
|
|
170
|
+
<Heading level={1}>Name your workspace</Heading>
|
|
171
|
+
<Field label="Workspace" hint="You can change this later." error={errors.workspace}>
|
|
172
|
+
{(id, describedBy) => (
|
|
173
|
+
<Input
|
|
174
|
+
id={id}
|
|
175
|
+
describedBy={describedBy}
|
|
176
|
+
value={answers.workspace}
|
|
177
|
+
onChange={set("workspace")}
|
|
178
|
+
autoComplete="organization"
|
|
179
|
+
placeholder="Tuesday study group"
|
|
180
|
+
invalid={errors.workspace !== undefined}
|
|
181
|
+
/>
|
|
182
|
+
)}
|
|
183
|
+
</Field>
|
|
184
|
+
</Stack>
|
|
185
|
+
)}
|
|
186
|
+
{step === 2 && (
|
|
187
|
+
<Stack gap={4}>
|
|
188
|
+
<Heading level={1}>How will you use it?</Heading>
|
|
189
|
+
<RadioGroup label="How you'll use it" value={answers.plan} onChange={set("plan")} options={PLANS} />
|
|
190
|
+
</Stack>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
<Row wrap={false}>
|
|
194
|
+
<Button
|
|
195
|
+
variant="quiet"
|
|
196
|
+
disabled={step === 0}
|
|
197
|
+
onClick={() => {
|
|
198
|
+
setErrors({});
|
|
199
|
+
setStep((s) => Math.max(0, s - 1));
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
Back
|
|
203
|
+
</Button>
|
|
204
|
+
<Spacer />
|
|
205
|
+
{last ? finish : <Button type="submit">Continue</Button>}
|
|
206
|
+
</Row>
|
|
207
|
+
</Stack>
|
|
208
|
+
</form>
|
|
209
|
+
)}
|
|
210
|
+
</Card>
|
|
211
|
+
<div className="pouf-toasts">
|
|
212
|
+
<Toaster />
|
|
213
|
+
</div>
|
|
214
|
+
</main>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Adapting
|
|
220
|
+
|
|
221
|
+
- A step is one entry in `STEPS`, one block of controls and one line in `stepError`; keep each step to a single question so `Continue` stays the obvious move.
|
|
222
|
+
- Keep every answer in the one `answers` object, never in the step's own state, so Back, Continue and signing in never lose a word.
|
|
223
|
+
- Answers the whole group should see (a team's name chosen by its founder) belong on a shared document gated on its own `can`, not on each person's setup.
|
|
224
|
+
- Use the answers elsewhere in the app by reading the person's `setup:<handle>` document; it is the single source of what they chose.
|
|
225
|
+
- A new write surface gets its own verdict: `can.create(draft)` with the fields the write stamps, `can.edit(doc)` or `can.delete(doc)` on the document it changes, always on `useVibe` of the database it writes.
|
|
226
|
+
|
|
227
|
+
Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/onboarding.json
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pouf-pricing.js","sourceRoot":"","sources":["../../jsr/llms/pouf-pricing.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,iBAAiB,GAAc;IAC1C,IAAI,EAAE,cAAc;IACpB,KAAK,EAAE,oBAAoB;IAC3B,WAAW,EAAE,8EAA8E;CAC5F,CAAC"}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Pouf Pricing
|
|
2
|
+
|
|
3
|
+
A plans page that compares a few tiers and lets someone ask for one: a yoga studio's memberships, a tutor's packages, a club's season passes, a freelancer's retainers. Reach for it whenever the app's core is "here is what each option costs, pick one". The plans themselves stay inline in the code. Picking a paid plan saves a request document, so the person sees what they asked for on the next visit and the app keeps a record of who asked for what; the owner's view of those requests is a short addition sketched under Adapting. It takes no payment and pretends to take none: settling up is arranged after the request, or by a separate payment integration.
|
|
4
|
+
|
|
5
|
+
Built from @vibes.diy/pouf components; persisted state lives in Fireproof. Every write surface is gated on `useVibe(dbName).can`.
|
|
6
|
+
|
|
7
|
+
## The block
|
|
8
|
+
|
|
9
|
+
One view with one job: compare and choose. An `Eyebrow` and a `Heading` with one `orange` `Highlight` open the page over a muted promise and a `Segmented` billing toggle (`tone="info"`) that recomputes every price shown, with an `up` `Badge` for the yearly saving. Three plan `Card`s sit in a `Grid`, the featured one on the default `Card` with an `info` `Popular` `Badge` and the others `variant="tight"`; each shows its price in a `Heading`, the period and any yearly saving in muted `Text`, and a checklist of features, each line an `ok` `Icon` beside `Text`. The featured plan's button is the view's one lead and the other paid plan's button is quiet, while the free plan shows a muted line instead of a button because there is nothing to request; each shows `loading` while its request lands. The person's latest request sits under the plans with a `Badge` read from its stored status (`info` received, `up` confirmed). Requesting waits on its `can` verdict from `useVibe("plans")`: a `Skeleton` inside an `aria-busy` wrapper in place of the buttons while it resolves, a `Sign in to choose` button wired to `useViewer().requestLogin()` for a visitor who has not signed in, and a sentence in the app's own words when requests are closed.
|
|
10
|
+
|
|
11
|
+
### `App.jsx`
|
|
12
|
+
```jsx
|
|
13
|
+
import React, { useState } from "react";
|
|
14
|
+
import { useFireproof } from "use-fireproof";
|
|
15
|
+
import { useViewer, useVibe } from "use-vibes";
|
|
16
|
+
import { Badge, Button, Card, Eyebrow, Grid, Heading, Highlight, Icon, Row, Segmented, Skeleton, Stack, Text } from "@vibes.diy/pouf";
|
|
17
|
+
import { Toaster, toast } from "@vibes.diy/pouf/interactive";
|
|
18
|
+
|
|
19
|
+
const PLANS = [
|
|
20
|
+
{ id: "taster", name: "Taster", monthly: 0, blurb: "Try a class before you choose.", features: ["Your first class free", "Mat hire included"] },
|
|
21
|
+
{
|
|
22
|
+
id: "regular",
|
|
23
|
+
name: "Regular",
|
|
24
|
+
monthly: 60,
|
|
25
|
+
blurb: "For two or three classes a week.",
|
|
26
|
+
featured: true,
|
|
27
|
+
features: ["Unlimited classes", "Free mat hire", "Book a week ahead", "One guest pass a month"],
|
|
28
|
+
},
|
|
29
|
+
{ id: "family", name: "Family", monthly: 150, blurb: "Up to four people on one plan.", features: ["Everything in Regular", "Four members", "Kids' Saturday class"] },
|
|
30
|
+
];
|
|
31
|
+
const CYCLES = [
|
|
32
|
+
{ value: "monthly", label: "Monthly" },
|
|
33
|
+
{ value: "yearly", label: "Yearly" },
|
|
34
|
+
];
|
|
35
|
+
const YEARLY_DISCOUNT = 0.2;
|
|
36
|
+
const REQUEST_STATUS = { received: { label: "Received", tone: "info" }, confirmed: { label: "Confirmed", tone: "up" } };
|
|
37
|
+
const MONEY = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 0 });
|
|
38
|
+
|
|
39
|
+
function priceOf(plan, cycle) {
|
|
40
|
+
return cycle === "yearly" ? Math.round(plan.monthly * 12 * (1 - YEARLY_DISCOUNT)) : plan.monthly;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function App() {
|
|
44
|
+
const { database, useLiveQuery } = useFireproof("plans");
|
|
45
|
+
const { docs: requests } = useLiveQuery("type", { key: "request" });
|
|
46
|
+
const { can, me } = useVibe("plans");
|
|
47
|
+
const { requestLogin } = useViewer();
|
|
48
|
+
|
|
49
|
+
const [cycle, setCycle] = useState("monthly");
|
|
50
|
+
const [sending, setSending] = useState(undefined);
|
|
51
|
+
// The plan and cycle just requested, held until the live query brings the request back.
|
|
52
|
+
const [sent, setSent] = useState(undefined);
|
|
53
|
+
|
|
54
|
+
const latest = me ? [...requests].filter((r) => r.authorHandle === me.userHandle).sort((a, b) => b.createdAt - a.createdAt)[0] : undefined;
|
|
55
|
+
|
|
56
|
+
// The verdict for the doc choose() writes for a plan, carrying the same fields it stamps.
|
|
57
|
+
const draftFor = (plan) => ({ type: "request", plan: plan.id, status: "received", authorHandle: me?.userHandle });
|
|
58
|
+
|
|
59
|
+
async function choose(plan) {
|
|
60
|
+
setSending(plan.id);
|
|
61
|
+
try {
|
|
62
|
+
// The request records the price as shown, so a later price change never rewrites what was asked for.
|
|
63
|
+
await database.put({ ...draftFor(plan), cycle, price: priceOf(plan, cycle), createdAt: Date.now() });
|
|
64
|
+
setSent(`${plan.id}:${cycle}`);
|
|
65
|
+
toast.success(`Thanks. We'll be in touch about ${plan.name}.`);
|
|
66
|
+
} finally {
|
|
67
|
+
setSending(undefined);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function action(plan) {
|
|
72
|
+
// A free plan needs no request: there is nothing to arrange.
|
|
73
|
+
if (plan.monthly === 0)
|
|
74
|
+
return (
|
|
75
|
+
<Text size="sm" muted>
|
|
76
|
+
No request needed. Come along to any open class.
|
|
77
|
+
</Text>
|
|
78
|
+
);
|
|
79
|
+
const verdict = can.create(draftFor(plan));
|
|
80
|
+
const quiet = plan.featured ? undefined : "quiet";
|
|
81
|
+
if (verdict.pending)
|
|
82
|
+
return (
|
|
83
|
+
<div aria-busy="true">
|
|
84
|
+
<Skeleton variant="row" />
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
if (!me)
|
|
88
|
+
return (
|
|
89
|
+
<Button block variant={quiet} onClick={requestLogin}>
|
|
90
|
+
Sign in to choose {plan.name}
|
|
91
|
+
</Button>
|
|
92
|
+
);
|
|
93
|
+
if (!verdict.ok) return <Text muted>New requests are paused for now. Ask us at the front desk.</Text>;
|
|
94
|
+
const asked = (latest?.plan === plan.id && latest?.cycle === cycle) || sent === `${plan.id}:${cycle}`;
|
|
95
|
+
return (
|
|
96
|
+
<Button block variant={quiet} disabled={asked || sending !== undefined} loading={sending === plan.id} onClick={() => choose(plan)}>
|
|
97
|
+
{asked ? "Requested" : `Choose ${plan.name}`}
|
|
98
|
+
</Button>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<main className="mx-auto max-w-5xl px-4 py-8">
|
|
104
|
+
<Stack gap={6}>
|
|
105
|
+
<Stack gap={3}>
|
|
106
|
+
<Eyebrow>Memberships</Eyebrow>
|
|
107
|
+
<Heading level={1}>
|
|
108
|
+
Find your <Highlight tone="orange">rhythm</Highlight>
|
|
109
|
+
</Heading>
|
|
110
|
+
<Text muted>Simple plans. Change or stop any time.</Text>
|
|
111
|
+
<Row gap={3}>
|
|
112
|
+
<Segmented label="Billing" tone="info" value={cycle} onChange={setCycle} options={CYCLES} />
|
|
113
|
+
{cycle === "yearly" && <Badge tone="up">Save {YEARLY_DISCOUNT * 100}%</Badge>}
|
|
114
|
+
</Row>
|
|
115
|
+
</Stack>
|
|
116
|
+
|
|
117
|
+
<Grid cols={3}>
|
|
118
|
+
{PLANS.map((plan) => {
|
|
119
|
+
const price = priceOf(plan, cycle);
|
|
120
|
+
const saving = plan.monthly * 12 - price;
|
|
121
|
+
return (
|
|
122
|
+
<Card key={plan.id} variant={plan.featured ? "default" : "tight"}>
|
|
123
|
+
<Stack gap={4}>
|
|
124
|
+
<Row justify="between">
|
|
125
|
+
<Heading level={3}>{plan.name}</Heading>
|
|
126
|
+
{plan.featured && <Badge tone="info">Popular</Badge>}
|
|
127
|
+
</Row>
|
|
128
|
+
<Stack gap={1}>
|
|
129
|
+
<Row gap={2}>
|
|
130
|
+
<Heading level={2}>{price === 0 ? "Free" : MONEY.format(price)}</Heading>
|
|
131
|
+
{price > 0 && <Text muted>{cycle === "yearly" ? "a year" : "a month"}</Text>}
|
|
132
|
+
</Row>
|
|
133
|
+
{cycle === "yearly" && saving > 0 && (
|
|
134
|
+
<Text size="sm" muted>
|
|
135
|
+
{MONEY.format(saving)} less than paying monthly
|
|
136
|
+
</Text>
|
|
137
|
+
)}
|
|
138
|
+
</Stack>
|
|
139
|
+
<Text size="sm" muted>
|
|
140
|
+
{plan.blurb}
|
|
141
|
+
</Text>
|
|
142
|
+
<Stack gap={2}>
|
|
143
|
+
{plan.features.map((f) => (
|
|
144
|
+
<Row key={f} gap={2} wrap={false}>
|
|
145
|
+
<Icon name="ok" size="sm" />
|
|
146
|
+
<Text size="sm">{f}</Text>
|
|
147
|
+
</Row>
|
|
148
|
+
))}
|
|
149
|
+
</Stack>
|
|
150
|
+
{action(plan)}
|
|
151
|
+
</Stack>
|
|
152
|
+
</Card>
|
|
153
|
+
);
|
|
154
|
+
})}
|
|
155
|
+
</Grid>
|
|
156
|
+
|
|
157
|
+
{latest && (
|
|
158
|
+
<Card variant="tight">
|
|
159
|
+
<Row justify="between">
|
|
160
|
+
<Stack gap={1}>
|
|
161
|
+
<Text>
|
|
162
|
+
Your request: {PLANS.find((p) => p.id === latest.plan)?.name ?? latest.plan}, {latest.cycle}
|
|
163
|
+
</Text>
|
|
164
|
+
<Text size="sm" muted>
|
|
165
|
+
Asked on {new Date(latest.createdAt).toLocaleDateString()}
|
|
166
|
+
</Text>
|
|
167
|
+
</Stack>
|
|
168
|
+
<Badge tone={REQUEST_STATUS[latest.status]?.tone ?? "info"}>{REQUEST_STATUS[latest.status]?.label ?? "Received"}</Badge>
|
|
169
|
+
</Row>
|
|
170
|
+
</Card>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
<Text size="sm" muted>
|
|
174
|
+
No card needed here. Choosing a plan sends us a request, and we arrange payment with you before it starts.
|
|
175
|
+
</Text>
|
|
176
|
+
</Stack>
|
|
177
|
+
<div className="pouf-toasts">
|
|
178
|
+
<Toaster />
|
|
179
|
+
</div>
|
|
180
|
+
</main>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Adapting
|
|
186
|
+
|
|
187
|
+
- Swap `PLANS` for the app's own tiers; keep prices as numbers and compute every shown price (and saving) from them, so the toggle can never show a stale figure.
|
|
188
|
+
- Exactly one plan is `featured`, and its button is the one lead; every other plan's button stays `variant="quiet"`.
|
|
189
|
+
- Keep the request a snapshot of the plan, cycle and price as shown: it is a record of what was offered, so a later price change must not touch it. The owner's view is a `Table` of `request` documents with the requester as a `ViewerTag`, each moved from `received` to `confirmed` behind `can.edit(request)`.
|
|
190
|
+
- Real payment is a separate integration with its own provider; until there is one, keep the "no card needed here" line truthful, and keep anything private (a phone number, an address) off the request unless `access.js` keeps each request between its sender and the owner.
|
|
191
|
+
- A new write surface gets its own verdict: `can.create(draft)` with the fields the write stamps, `can.edit(doc)` or `can.delete(doc)` on the document it changes, always on `useVibe` of the database it writes.
|
|
192
|
+
|
|
193
|
+
Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/pricing.json
|