@homie-rocks/studio 0.48.4 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/app-starters/gathering/README.md +4 -0
- package/app-starters/gathering/app.json +162 -0
- package/app-starters/gathering/index.html +3 -0
- package/app-starters/gathering/map/main.json +1 -0
- package/app-starters/gathering/src/move.ts +3 -0
- package/app-starters/gathering/src/rules.ts +21 -0
- package/app-starters/gathering/src/view.ts +74 -0
- package/app-starters/giving/README.md +5 -0
- package/app-starters/giving/app.json +162 -0
- package/app-starters/giving/index.html +3 -0
- package/app-starters/giving/map/main.json +1 -0
- package/app-starters/giving/src/move.ts +3 -0
- package/app-starters/giving/src/rules.ts +21 -0
- package/app-starters/giving/src/view.ts +74 -0
- package/app-starters/jobs/README.md +4 -0
- package/app-starters/jobs/app.json +162 -0
- package/app-starters/jobs/index.html +3 -0
- package/app-starters/jobs/map/main.json +1 -0
- package/app-starters/jobs/src/move.ts +3 -0
- package/app-starters/jobs/src/rules.ts +21 -0
- package/app-starters/jobs/src/view.ts +74 -0
- package/app-starters/quiz/README.md +4 -0
- package/app-starters/quiz/app.json +162 -0
- package/app-starters/quiz/index.html +3 -0
- package/app-starters/quiz/map/main.json +1 -0
- package/app-starters/quiz/src/move.ts +3 -0
- package/app-starters/quiz/src/rules.ts +21 -0
- package/app-starters/quiz/src/view.ts +74 -0
- package/app-starters/welcome/app.json +25 -2
- package/app-starters/welcome/index.html +1 -1
- package/app-starters/welcome/src/view.ts +20 -8
- package/bin/homie-studio.mjs +15 -11
- package/guides/app/SKILL.md +72 -0
- package/guides/parts/SKILL.md +158 -0
- package/guides/shop/SKILL.md +149 -0
- package/guides/studio-setup/SKILL.md +419 -0
- package/lib/authoring-guides.mjs +5 -4
- package/lib/build.mjs +5 -4
- package/lib/check-site.mjs +6 -6
- package/lib/check.mjs +8 -1
- package/lib/dev.mjs +4 -2
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +13 -8
- package/lib/mcp-tools.mjs +10 -9
- package/lib/parts.mjs +6 -6
- package/lib/scaffold.mjs +35 -9
- package/lib/shop-links.mjs +2 -2
- package/lib/shop.mjs +5 -3
- package/lib/studio.mjs +5 -4
- package/lib/template-history.json +42 -0
- package/package.json +4 -4
- package/worker/app-format.mjs +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +2 -1
- package/worker/parts.mjs +4 -4
- package/worker/shop-page.mjs +21 -15
- package/worker/shop-rules.mjs +4 -3
- package/worker/site.mjs +4 -2
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "jobs",
|
|
3
|
+
"name": "At your service",
|
|
4
|
+
"blurb": "Request a service. The team confirms your booking and updates the board.",
|
|
5
|
+
"entry": "src/view.ts",
|
|
6
|
+
"roles": {
|
|
7
|
+
"customer": {
|
|
8
|
+
"signIn": false,
|
|
9
|
+
"can": [
|
|
10
|
+
"read:entries",
|
|
11
|
+
"create:entries"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"wall": {
|
|
15
|
+
"signIn": false,
|
|
16
|
+
"can": [
|
|
17
|
+
"read:entries"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"staff": {
|
|
21
|
+
"signIn": true,
|
|
22
|
+
"can": [
|
|
23
|
+
"read:entries",
|
|
24
|
+
"update:entries",
|
|
25
|
+
"delete:entries"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"surfaces": {
|
|
30
|
+
"phone": "customer",
|
|
31
|
+
"wall": "wall",
|
|
32
|
+
"kiosk": "customer",
|
|
33
|
+
"tablet": "staff"
|
|
34
|
+
},
|
|
35
|
+
"words": {
|
|
36
|
+
"open": "Open",
|
|
37
|
+
"join": "Scan to join",
|
|
38
|
+
"one": "person",
|
|
39
|
+
"many": "people",
|
|
40
|
+
"action": "Request booking",
|
|
41
|
+
"total": "Requests",
|
|
42
|
+
"choice": "Choose an option",
|
|
43
|
+
"monthly": "Gift frequency",
|
|
44
|
+
"once": "One time",
|
|
45
|
+
"month": "Monthly",
|
|
46
|
+
"give": "Give securely on Stripe",
|
|
47
|
+
"goal": "Intention goal \u00b7 not payments received",
|
|
48
|
+
"prompt": "Choose what works for you.",
|
|
49
|
+
"finished": "All three answers are in. Thank you!",
|
|
50
|
+
"complete": "Mark complete",
|
|
51
|
+
"done": "Complete",
|
|
52
|
+
"pending": "Requested",
|
|
53
|
+
"thanks": "Thank you \u2014 the wall is up to date.",
|
|
54
|
+
"live": "Together \u00b7 live",
|
|
55
|
+
"reconnecting": "Reconnecting\u2026"
|
|
56
|
+
},
|
|
57
|
+
"records": {
|
|
58
|
+
"persist": true,
|
|
59
|
+
"collections": {
|
|
60
|
+
"entries": {
|
|
61
|
+
"fields": {
|
|
62
|
+
"choice": {
|
|
63
|
+
"type": "string",
|
|
64
|
+
"required": true,
|
|
65
|
+
"maxLength": 100
|
|
66
|
+
},
|
|
67
|
+
"status": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"required": true,
|
|
70
|
+
"maxLength": 60
|
|
71
|
+
},
|
|
72
|
+
"amount": {
|
|
73
|
+
"type": "number",
|
|
74
|
+
"required": true
|
|
75
|
+
},
|
|
76
|
+
"monthly": {
|
|
77
|
+
"type": "boolean",
|
|
78
|
+
"required": true
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"parts": [],
|
|
85
|
+
"netplay": {
|
|
86
|
+
"v": 1,
|
|
87
|
+
"public": true,
|
|
88
|
+
"version": "1",
|
|
89
|
+
"params": [
|
|
90
|
+
"role",
|
|
91
|
+
"surface",
|
|
92
|
+
"ticket"
|
|
93
|
+
]
|
|
94
|
+
},
|
|
95
|
+
"playerCard": false,
|
|
96
|
+
"chat": false,
|
|
97
|
+
"agents": {
|
|
98
|
+
"vote": false
|
|
99
|
+
},
|
|
100
|
+
"watch": "overview",
|
|
101
|
+
"screen": {
|
|
102
|
+
"join": "bottom-right",
|
|
103
|
+
"share": "top-right"
|
|
104
|
+
},
|
|
105
|
+
"needs": {
|
|
106
|
+
"three": "0.185.1",
|
|
107
|
+
"@homie-rocks/camera": "0.2.1",
|
|
108
|
+
"@homie-rocks/geom": "0.1.1",
|
|
109
|
+
"@types/three": "0.185.1"
|
|
110
|
+
},
|
|
111
|
+
"check": {
|
|
112
|
+
"action": "[data-action]",
|
|
113
|
+
"observe": "[data-count]"
|
|
114
|
+
},
|
|
115
|
+
"landing": {
|
|
116
|
+
"pitch": "Request a service. The team confirms your booking and updates the board."
|
|
117
|
+
},
|
|
118
|
+
"room": {
|
|
119
|
+
"host": "server"
|
|
120
|
+
},
|
|
121
|
+
"players": {
|
|
122
|
+
"min": 1,
|
|
123
|
+
"max": 32
|
|
124
|
+
},
|
|
125
|
+
"starter": {
|
|
126
|
+
"kind": "jobs",
|
|
127
|
+
"choices": [
|
|
128
|
+
"Safety check",
|
|
129
|
+
"Tune-up",
|
|
130
|
+
"Full service"
|
|
131
|
+
],
|
|
132
|
+
"color": "#183b55",
|
|
133
|
+
"currency": "CAD",
|
|
134
|
+
"goal": 2500,
|
|
135
|
+
"questions": [
|
|
136
|
+
{
|
|
137
|
+
"text": "Which planet is called the red planet?",
|
|
138
|
+
"choices": [
|
|
139
|
+
"Venus",
|
|
140
|
+
"Mars",
|
|
141
|
+
"Jupiter"
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"text": "How many sides does a hexagon have?",
|
|
146
|
+
"choices": [
|
|
147
|
+
"5",
|
|
148
|
+
"6",
|
|
149
|
+
"8"
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"text": "Which is a mammal?",
|
|
154
|
+
"choices": [
|
|
155
|
+
"Dolphin",
|
|
156
|
+
"Trout",
|
|
157
|
+
"Octopus"
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Live together</title><style>
|
|
2
|
+
*{box-sizing:border-box}body{margin:0;background:var(--tone,#183b55);color:#fff7df;font-family:system-ui,sans-serif}main{min-height:100svh;padding:clamp(24px,5vw,64px);display:grid;grid-template-columns:1.1fr 1fr;gap:40px;align-items:center}h1{font-size:clamp(44px,6vw,88px);line-height:1;letter-spacing:-.05em;margin:20px 0}p{line-height:1.6;max-width:40em}#connection{font-size:12px;text-transform:uppercase;letter-spacing:.18em}.panel{background:#fff7df;color:#20352e;border-radius:28px;padding:28px;box-shadow:0 30px 80px #0003}#count{font-size:80px;font-weight:800;line-height:1}ul{padding:0;list-style:none}li{padding:12px 0;border-bottom:1px solid #20352e22}button,select,a{font:inherit;min-height:48px;border-radius:12px}button,select{padding:10px 16px;width:100%;margin-top:8px}button{background:var(--tone);color:#fff7df;border:0;cursor:pointer}button:disabled{opacity:.5}a{display:inline-flex;align-items:center;color:inherit}label{display:block;margin-top:14px}select{background:white;color:#20352e;border:1px solid #20352e66}progress{width:100%;height:22px;accent-color:var(--tone)}#status{min-height:24px}#amount{font-size:40px;font-weight:700}button:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid #df9325;outline-offset:4px}.wall #controls{display:none}#giving-link[hidden],[hidden]{display:none!important}@media(max-width:650px){main{display:block;padding:72px 22px 24px}h1{font-size:46px}.panel{margin-top:24px;padding:22px}#count{font-size:58px}li{font-size:14px}}@media(max-height:520px) and (min-width:651px){main{padding:65px 25px 20px;gap:22px}h1{font-size:38px}.panel{padding:18px}}
|
|
3
|
+
</style><main><header><span id="connection"></span><h1></h1><p id="intro"></p><div id="goal"><p id="goal-label"></p><div><span id="amount"></span> / <span id="target"></span></div><progress></progress></div><ul id="entries" aria-live="polite"></ul></header><section class="panel"><div id="label"></div><div id="count" data-count>0</div><div id="controls"><p id="prompt"></p><label><span id="choice-label"></span><select id="choice"></select></label><label id="frequency"><span id="frequency-label"></span><select id="monthly"><option id="once" value="once"></option><option id="month" value="month"></option></select></label><button data-action></button><a id="giving-link" href="/shop/" target="_blank" rel="noopener"></a></div><p id="status" role="status"></p></section></main><script type="module" src="./assets/main.js"></script></html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"bounds":{"min":[-1,-1],"max":[1,1]},"boxes":[],"spots":{"start":[[0,0]]}}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Transient scene truth lives in the room; authorized queue records live in AppRecords.
|
|
2
|
+
// A phone has a logical stationary seat anchor. The wall watches without a seat.
|
|
3
|
+
import { defineRules, f } from '@homie-rocks/studio/rules';
|
|
4
|
+
import { move } from './move';
|
|
5
|
+
export default defineRules({
|
|
6
|
+
contract: 2, space: { dims: 2 }, move,
|
|
7
|
+
shared: { revision: f.u32() },
|
|
8
|
+
shapes: { events: { refresh: {} }, commands: { refresh: {} }, effects: { refresh: {} } },
|
|
9
|
+
entities: {
|
|
10
|
+
participant: {
|
|
11
|
+
player: true,
|
|
12
|
+
body: { shape: 'circle', radius: 0.1, maxSpeed: 0 },
|
|
13
|
+
commands: { refresh(world, self) { world.sendRoom('refresh', {}); } },
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
room: {
|
|
17
|
+
rounds: { seconds: 0, breakSeconds: 0 }, bots: { keep: 0 },
|
|
18
|
+
join() { return { kind: 'participant', at: { x: 0, y: 0 } }; },
|
|
19
|
+
on: { refresh(world) { world.shared.revision += 1; world.emit('refresh', { x: 0, y: 0 }, {}); } },
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import app from '../app.json';
|
|
2
|
+
import { openRoom } from '@homie-rocks/studio/rules/view';
|
|
3
|
+
import type rules from './rules';
|
|
4
|
+
import { createAppRecords, type AppRecord } from '@homie-rocks/studio/apps';
|
|
5
|
+
import { randomId } from '@homie-rocks/studio/links';
|
|
6
|
+
type Entry = { choice: string; status: string; amount: number; monthly: boolean };
|
|
7
|
+
const room = openRoom<typeof rules>();
|
|
8
|
+
const net = room.net;
|
|
9
|
+
const role = net.params.role ?? 'customer';
|
|
10
|
+
const words: Record<string, string> = app.words;
|
|
11
|
+
const config = app.starter;
|
|
12
|
+
const $ = <T extends HTMLElement = HTMLElement>(s: string) => document.querySelector<T>(s)!;
|
|
13
|
+
const records = createAppRecords<Entry>({ collection: 'entries' });
|
|
14
|
+
let rows: AppRecord<Entry>[] = [], busy = false, ready = false, question = 0;
|
|
15
|
+
const action = $<HTMLButtonElement>('[data-action]');
|
|
16
|
+
document.body.className = role;
|
|
17
|
+
document.body.style.setProperty('--tone', config.color);
|
|
18
|
+
$('h1').textContent = app.name; $('#intro').textContent = app.blurb;
|
|
19
|
+
$('#label').textContent = words.total; action.textContent = words.action;
|
|
20
|
+
$('#choice-label').textContent = words.choice;
|
|
21
|
+
$('#frequency-label').textContent = words.monthly;
|
|
22
|
+
$('#once').textContent = words.once; $('#month').textContent = words.month;
|
|
23
|
+
$('#giving-link').textContent = words.give;
|
|
24
|
+
$('#giving-link').hidden = config.kind !== 'giving';
|
|
25
|
+
$('#frequency').hidden = config.kind !== 'giving';
|
|
26
|
+
$('#goal').hidden = config.kind !== 'giving';
|
|
27
|
+
$('#controls').hidden = role === 'wall';
|
|
28
|
+
const select = $<HTMLSelectElement>('#choice');
|
|
29
|
+
function choices() {
|
|
30
|
+
select.replaceChildren();
|
|
31
|
+
const list = config.kind === 'quiz' ? config.questions[question]?.choices ?? [] : config.choices;
|
|
32
|
+
for (const value of list) { const option = document.createElement('option'); option.value = value; option.textContent = value; select.append(option); }
|
|
33
|
+
$('#prompt').textContent = config.kind === 'quiz' ? config.questions[question]?.text ?? words.finished : words.prompt;
|
|
34
|
+
action.hidden = config.kind === 'quiz' && question >= config.questions.length;
|
|
35
|
+
}
|
|
36
|
+
choices();
|
|
37
|
+
function paint() {
|
|
38
|
+
$('[data-count]').textContent = String(rows.length);
|
|
39
|
+
const amount = rows.reduce((n, r) => n + r.data.amount, 0);
|
|
40
|
+
$('#amount').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(amount);
|
|
41
|
+
$('#target').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(config.goal);
|
|
42
|
+
$<HTMLProgressElement>('progress').max = config.goal; $<HTMLProgressElement>('progress').value = amount;
|
|
43
|
+
$('#goal-label').textContent = words.goal;
|
|
44
|
+
action.disabled = busy || !ready;
|
|
45
|
+
$('#entries').replaceChildren();
|
|
46
|
+
for (const row of rows.slice(-6).reverse()) {
|
|
47
|
+
const li = document.createElement('li');
|
|
48
|
+
li.textContent = `${row.data.choice} · ${row.data.status}${row.data.monthly ? ' · ' + words.month : ''}`;
|
|
49
|
+
if (role === 'staff' && row.data.status !== words.done) {
|
|
50
|
+
const button = document.createElement('button'); button.textContent = words.complete;
|
|
51
|
+
button.onclick = () => { void records.update(row, { ...row.data, status: words.done }).then(refresh).catch(e => { $('#status').textContent = e.message; }); }; li.append(button);
|
|
52
|
+
}
|
|
53
|
+
$('#entries').append(li);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function refresh() {
|
|
57
|
+
try { rows = await records.list(); ready = true; paint(); net.playable(); }
|
|
58
|
+
catch (error) { $('#status').textContent = (error as Error).message; }
|
|
59
|
+
}
|
|
60
|
+
action.onclick = async () => {
|
|
61
|
+
busy = true; paint();
|
|
62
|
+
try {
|
|
63
|
+
const choice = select.value;
|
|
64
|
+
await records.create({ choice: config.kind === 'quiz' ? `${question + 1}. ${choice}` : choice, status: words.pending, amount: config.kind === 'giving' ? Number(choice) : 0, monthly: $<HTMLSelectElement>('#monthly').value === 'month' }, randomId());
|
|
65
|
+
$('#status').textContent = words.thanks;
|
|
66
|
+
if (config.kind === 'quiz') { question++; choices(); }
|
|
67
|
+
room.command('refresh', {}); await refresh();
|
|
68
|
+
} catch (error) { $('#status').textContent = (error as Error).message; }
|
|
69
|
+
finally { busy = false; paint(); }
|
|
70
|
+
};
|
|
71
|
+
room.on('refresh', () => { void refresh(); });
|
|
72
|
+
net.on('link', () => { void refresh(); });
|
|
73
|
+
setInterval(() => { $('#connection').textContent = net.link === 'online' ? words.live : words.reconnecting; void refresh(); }, 2000);
|
|
74
|
+
void refresh();
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "quiz",
|
|
3
|
+
"name": "Quiz night",
|
|
4
|
+
"blurb": "A casual three-question quiz. Play along on your phone.",
|
|
5
|
+
"entry": "src/view.ts",
|
|
6
|
+
"roles": {
|
|
7
|
+
"customer": {
|
|
8
|
+
"signIn": false,
|
|
9
|
+
"can": [
|
|
10
|
+
"read:entries",
|
|
11
|
+
"create:entries"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"wall": {
|
|
15
|
+
"signIn": false,
|
|
16
|
+
"can": [
|
|
17
|
+
"read:entries"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"staff": {
|
|
21
|
+
"signIn": true,
|
|
22
|
+
"can": [
|
|
23
|
+
"read:entries",
|
|
24
|
+
"update:entries",
|
|
25
|
+
"delete:entries"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"surfaces": {
|
|
30
|
+
"phone": "customer",
|
|
31
|
+
"wall": "wall",
|
|
32
|
+
"kiosk": "customer",
|
|
33
|
+
"tablet": "staff"
|
|
34
|
+
},
|
|
35
|
+
"words": {
|
|
36
|
+
"open": "Open",
|
|
37
|
+
"join": "Scan to join",
|
|
38
|
+
"one": "person",
|
|
39
|
+
"many": "people",
|
|
40
|
+
"action": "Submit answer",
|
|
41
|
+
"total": "Answers",
|
|
42
|
+
"choice": "Choose an option",
|
|
43
|
+
"monthly": "Gift frequency",
|
|
44
|
+
"once": "One time",
|
|
45
|
+
"month": "Monthly",
|
|
46
|
+
"give": "Give securely on Stripe",
|
|
47
|
+
"goal": "Intention goal \u00b7 not payments received",
|
|
48
|
+
"prompt": "Choose what works for you.",
|
|
49
|
+
"finished": "All three answers are in. Thank you!",
|
|
50
|
+
"complete": "Mark complete",
|
|
51
|
+
"done": "Complete",
|
|
52
|
+
"pending": "Recorded",
|
|
53
|
+
"thanks": "Thank you \u2014 the wall is up to date.",
|
|
54
|
+
"live": "Together \u00b7 live",
|
|
55
|
+
"reconnecting": "Reconnecting\u2026"
|
|
56
|
+
},
|
|
57
|
+
"records": {
|
|
58
|
+
"persist": true,
|
|
59
|
+
"collections": {
|
|
60
|
+
"entries": {
|
|
61
|
+
"fields": {
|
|
62
|
+
"choice": {
|
|
63
|
+
"type": "string",
|
|
64
|
+
"required": true,
|
|
65
|
+
"maxLength": 100
|
|
66
|
+
},
|
|
67
|
+
"status": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"required": true,
|
|
70
|
+
"maxLength": 60
|
|
71
|
+
},
|
|
72
|
+
"amount": {
|
|
73
|
+
"type": "number",
|
|
74
|
+
"required": true
|
|
75
|
+
},
|
|
76
|
+
"monthly": {
|
|
77
|
+
"type": "boolean",
|
|
78
|
+
"required": true
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"parts": [],
|
|
85
|
+
"netplay": {
|
|
86
|
+
"v": 1,
|
|
87
|
+
"public": true,
|
|
88
|
+
"version": "1",
|
|
89
|
+
"params": [
|
|
90
|
+
"role",
|
|
91
|
+
"surface",
|
|
92
|
+
"ticket"
|
|
93
|
+
]
|
|
94
|
+
},
|
|
95
|
+
"playerCard": false,
|
|
96
|
+
"chat": false,
|
|
97
|
+
"agents": {
|
|
98
|
+
"vote": false
|
|
99
|
+
},
|
|
100
|
+
"watch": "overview",
|
|
101
|
+
"screen": {
|
|
102
|
+
"join": "bottom-right",
|
|
103
|
+
"share": "top-right"
|
|
104
|
+
},
|
|
105
|
+
"needs": {
|
|
106
|
+
"three": "0.185.1",
|
|
107
|
+
"@homie-rocks/camera": "0.2.1",
|
|
108
|
+
"@homie-rocks/geom": "0.1.1",
|
|
109
|
+
"@types/three": "0.185.1"
|
|
110
|
+
},
|
|
111
|
+
"check": {
|
|
112
|
+
"action": "[data-action]",
|
|
113
|
+
"observe": "[data-count]"
|
|
114
|
+
},
|
|
115
|
+
"landing": {
|
|
116
|
+
"pitch": "A casual three-question quiz. Play along on your phone."
|
|
117
|
+
},
|
|
118
|
+
"room": {
|
|
119
|
+
"host": "server"
|
|
120
|
+
},
|
|
121
|
+
"players": {
|
|
122
|
+
"min": 1,
|
|
123
|
+
"max": 32
|
|
124
|
+
},
|
|
125
|
+
"starter": {
|
|
126
|
+
"kind": "quiz",
|
|
127
|
+
"choices": [
|
|
128
|
+
"Venus",
|
|
129
|
+
"Mars",
|
|
130
|
+
"Jupiter"
|
|
131
|
+
],
|
|
132
|
+
"color": "#562f49",
|
|
133
|
+
"currency": "CAD",
|
|
134
|
+
"goal": 2500,
|
|
135
|
+
"questions": [
|
|
136
|
+
{
|
|
137
|
+
"text": "Which planet is called the red planet?",
|
|
138
|
+
"choices": [
|
|
139
|
+
"Venus",
|
|
140
|
+
"Mars",
|
|
141
|
+
"Jupiter"
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"text": "How many sides does a hexagon have?",
|
|
146
|
+
"choices": [
|
|
147
|
+
"5",
|
|
148
|
+
"6",
|
|
149
|
+
"8"
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"text": "Which is a mammal?",
|
|
154
|
+
"choices": [
|
|
155
|
+
"Dolphin",
|
|
156
|
+
"Trout",
|
|
157
|
+
"Octopus"
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Live together</title><style>
|
|
2
|
+
*{box-sizing:border-box}body{margin:0;background:var(--tone,#183b55);color:#fff7df;font-family:system-ui,sans-serif}main{min-height:100svh;padding:clamp(24px,5vw,64px);display:grid;grid-template-columns:1.1fr 1fr;gap:40px;align-items:center}h1{font-size:clamp(44px,6vw,88px);line-height:1;letter-spacing:-.05em;margin:20px 0}p{line-height:1.6;max-width:40em}#connection{font-size:12px;text-transform:uppercase;letter-spacing:.18em}.panel{background:#fff7df;color:#20352e;border-radius:28px;padding:28px;box-shadow:0 30px 80px #0003}#count{font-size:80px;font-weight:800;line-height:1}ul{padding:0;list-style:none}li{padding:12px 0;border-bottom:1px solid #20352e22}button,select,a{font:inherit;min-height:48px;border-radius:12px}button,select{padding:10px 16px;width:100%;margin-top:8px}button{background:var(--tone);color:#fff7df;border:0;cursor:pointer}button:disabled{opacity:.5}a{display:inline-flex;align-items:center;color:inherit}label{display:block;margin-top:14px}select{background:white;color:#20352e;border:1px solid #20352e66}progress{width:100%;height:22px;accent-color:var(--tone)}#status{min-height:24px}#amount{font-size:40px;font-weight:700}button:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid #df9325;outline-offset:4px}.wall #controls{display:none}#giving-link[hidden],[hidden]{display:none!important}@media(max-width:650px){main{display:block;padding:72px 22px 24px}h1{font-size:46px}.panel{margin-top:24px;padding:22px}#count{font-size:58px}li{font-size:14px}}@media(max-height:520px) and (min-width:651px){main{padding:65px 25px 20px;gap:22px}h1{font-size:38px}.panel{padding:18px}}
|
|
3
|
+
</style><main><header><span id="connection"></span><h1></h1><p id="intro"></p><div id="goal"><p id="goal-label"></p><div><span id="amount"></span> / <span id="target"></span></div><progress></progress></div><ul id="entries" aria-live="polite"></ul></header><section class="panel"><div id="label"></div><div id="count" data-count>0</div><div id="controls"><p id="prompt"></p><label><span id="choice-label"></span><select id="choice"></select></label><label id="frequency"><span id="frequency-label"></span><select id="monthly"><option id="once" value="once"></option><option id="month" value="month"></option></select></label><button data-action></button><a id="giving-link" href="/shop/" target="_blank" rel="noopener"></a></div><p id="status" role="status"></p></section></main><script type="module" src="./assets/main.js"></script></html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"bounds":{"min":[-1,-1],"max":[1,1]},"boxes":[],"spots":{"start":[[0,0]]}}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Transient scene truth lives in the room; authorized queue records live in AppRecords.
|
|
2
|
+
// A phone has a logical stationary seat anchor. The wall watches without a seat.
|
|
3
|
+
import { defineRules, f } from '@homie-rocks/studio/rules';
|
|
4
|
+
import { move } from './move';
|
|
5
|
+
export default defineRules({
|
|
6
|
+
contract: 2, space: { dims: 2 }, move,
|
|
7
|
+
shared: { revision: f.u32() },
|
|
8
|
+
shapes: { events: { refresh: {} }, commands: { refresh: {} }, effects: { refresh: {} } },
|
|
9
|
+
entities: {
|
|
10
|
+
participant: {
|
|
11
|
+
player: true,
|
|
12
|
+
body: { shape: 'circle', radius: 0.1, maxSpeed: 0 },
|
|
13
|
+
commands: { refresh(world, self) { world.sendRoom('refresh', {}); } },
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
room: {
|
|
17
|
+
rounds: { seconds: 0, breakSeconds: 0 }, bots: { keep: 0 },
|
|
18
|
+
join() { return { kind: 'participant', at: { x: 0, y: 0 } }; },
|
|
19
|
+
on: { refresh(world) { world.shared.revision += 1; world.emit('refresh', { x: 0, y: 0 }, {}); } },
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import app from '../app.json';
|
|
2
|
+
import { openRoom } from '@homie-rocks/studio/rules/view';
|
|
3
|
+
import type rules from './rules';
|
|
4
|
+
import { createAppRecords, type AppRecord } from '@homie-rocks/studio/apps';
|
|
5
|
+
import { randomId } from '@homie-rocks/studio/links';
|
|
6
|
+
type Entry = { choice: string; status: string; amount: number; monthly: boolean };
|
|
7
|
+
const room = openRoom<typeof rules>();
|
|
8
|
+
const net = room.net;
|
|
9
|
+
const role = net.params.role ?? 'customer';
|
|
10
|
+
const words: Record<string, string> = app.words;
|
|
11
|
+
const config = app.starter;
|
|
12
|
+
const $ = <T extends HTMLElement = HTMLElement>(s: string) => document.querySelector<T>(s)!;
|
|
13
|
+
const records = createAppRecords<Entry>({ collection: 'entries' });
|
|
14
|
+
let rows: AppRecord<Entry>[] = [], busy = false, ready = false, question = 0;
|
|
15
|
+
const action = $<HTMLButtonElement>('[data-action]');
|
|
16
|
+
document.body.className = role;
|
|
17
|
+
document.body.style.setProperty('--tone', config.color);
|
|
18
|
+
$('h1').textContent = app.name; $('#intro').textContent = app.blurb;
|
|
19
|
+
$('#label').textContent = words.total; action.textContent = words.action;
|
|
20
|
+
$('#choice-label').textContent = words.choice;
|
|
21
|
+
$('#frequency-label').textContent = words.monthly;
|
|
22
|
+
$('#once').textContent = words.once; $('#month').textContent = words.month;
|
|
23
|
+
$('#giving-link').textContent = words.give;
|
|
24
|
+
$('#giving-link').hidden = config.kind !== 'giving';
|
|
25
|
+
$('#frequency').hidden = config.kind !== 'giving';
|
|
26
|
+
$('#goal').hidden = config.kind !== 'giving';
|
|
27
|
+
$('#controls').hidden = role === 'wall';
|
|
28
|
+
const select = $<HTMLSelectElement>('#choice');
|
|
29
|
+
function choices() {
|
|
30
|
+
select.replaceChildren();
|
|
31
|
+
const list = config.kind === 'quiz' ? config.questions[question]?.choices ?? [] : config.choices;
|
|
32
|
+
for (const value of list) { const option = document.createElement('option'); option.value = value; option.textContent = value; select.append(option); }
|
|
33
|
+
$('#prompt').textContent = config.kind === 'quiz' ? config.questions[question]?.text ?? words.finished : words.prompt;
|
|
34
|
+
action.hidden = config.kind === 'quiz' && question >= config.questions.length;
|
|
35
|
+
}
|
|
36
|
+
choices();
|
|
37
|
+
function paint() {
|
|
38
|
+
$('[data-count]').textContent = String(rows.length);
|
|
39
|
+
const amount = rows.reduce((n, r) => n + r.data.amount, 0);
|
|
40
|
+
$('#amount').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(amount);
|
|
41
|
+
$('#target').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(config.goal);
|
|
42
|
+
$<HTMLProgressElement>('progress').max = config.goal; $<HTMLProgressElement>('progress').value = amount;
|
|
43
|
+
$('#goal-label').textContent = words.goal;
|
|
44
|
+
action.disabled = busy || !ready;
|
|
45
|
+
$('#entries').replaceChildren();
|
|
46
|
+
for (const row of rows.slice(-6).reverse()) {
|
|
47
|
+
const li = document.createElement('li');
|
|
48
|
+
li.textContent = `${row.data.choice} · ${row.data.status}${row.data.monthly ? ' · ' + words.month : ''}`;
|
|
49
|
+
if (role === 'staff' && row.data.status !== words.done) {
|
|
50
|
+
const button = document.createElement('button'); button.textContent = words.complete;
|
|
51
|
+
button.onclick = () => { void records.update(row, { ...row.data, status: words.done }).then(refresh).catch(e => { $('#status').textContent = e.message; }); }; li.append(button);
|
|
52
|
+
}
|
|
53
|
+
$('#entries').append(li);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function refresh() {
|
|
57
|
+
try { rows = await records.list(); ready = true; paint(); net.playable(); }
|
|
58
|
+
catch (error) { $('#status').textContent = (error as Error).message; }
|
|
59
|
+
}
|
|
60
|
+
action.onclick = async () => {
|
|
61
|
+
busy = true; paint();
|
|
62
|
+
try {
|
|
63
|
+
const choice = select.value;
|
|
64
|
+
await records.create({ choice: config.kind === 'quiz' ? `${question + 1}. ${choice}` : choice, status: words.pending, amount: config.kind === 'giving' ? Number(choice) : 0, monthly: $<HTMLSelectElement>('#monthly').value === 'month' }, randomId());
|
|
65
|
+
$('#status').textContent = words.thanks;
|
|
66
|
+
if (config.kind === 'quiz') { question++; choices(); }
|
|
67
|
+
room.command('refresh', {}); await refresh();
|
|
68
|
+
} catch (error) { $('#status').textContent = (error as Error).message; }
|
|
69
|
+
finally { busy = false; paint(); }
|
|
70
|
+
};
|
|
71
|
+
room.on('refresh', () => { void refresh(); });
|
|
72
|
+
net.on('link', () => { void refresh(); });
|
|
73
|
+
setInterval(() => { $('#connection').textContent = net.link === 'online' ? words.live : words.reconnecting; void refresh(); }, 2000);
|
|
74
|
+
void refresh();
|
|
@@ -36,7 +36,29 @@
|
|
|
36
36
|
"open": "Open",
|
|
37
37
|
"join": "Scan to join",
|
|
38
38
|
"one": "person",
|
|
39
|
-
"many": "people"
|
|
39
|
+
"many": "people",
|
|
40
|
+
"eyebrow": "A space for everyone",
|
|
41
|
+
"wallTitle": "",
|
|
42
|
+
"staffTitle": "Welcome desk",
|
|
43
|
+
"waiting": "waiting",
|
|
44
|
+
"panel": "Now welcoming",
|
|
45
|
+
"yourTicket": "Your ticket",
|
|
46
|
+
"welcome": "Welcome in",
|
|
47
|
+
"home": "Make yourself at home.",
|
|
48
|
+
"ready": "Ready when you are.",
|
|
49
|
+
"called": "It\u2019s your turn. Come on over!",
|
|
50
|
+
"ahead": "You have a place. {n} ahead of you.",
|
|
51
|
+
"guest": "No account needed. Just a place in line.",
|
|
52
|
+
"waitingDetail": "{n} {people} waiting for a welcome.",
|
|
53
|
+
"empty": "The next arrival will appear here.",
|
|
54
|
+
"joinAction": "Join the queue",
|
|
55
|
+
"nextAction": "Call the next person",
|
|
56
|
+
"ticket": "No. {label}",
|
|
57
|
+
"keepTicket": "Keep your ticket",
|
|
58
|
+
"live": "Here together \u00b7 live",
|
|
59
|
+
"reconnecting": "Reconnecting\u2026",
|
|
60
|
+
"footer": "ONE PLACE \u00b7 TOGETHER",
|
|
61
|
+
"fallback": "The 3D scene is unavailable on this device. Your live controls still work."
|
|
40
62
|
},
|
|
41
63
|
"records": {
|
|
42
64
|
"persist": true,
|
|
@@ -89,7 +111,8 @@
|
|
|
89
111
|
"needs": {
|
|
90
112
|
"three": "0.185.1",
|
|
91
113
|
"@homie-rocks/camera": "0.2.1",
|
|
92
|
-
"@homie-rocks/geom": "0.1.1"
|
|
114
|
+
"@homie-rocks/geom": "0.1.1",
|
|
115
|
+
"@types/three": "0.185.1"
|
|
93
116
|
},
|
|
94
117
|
"check": {
|
|
95
118
|
"action": "[data-join]",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Welcome together</title>
|
|
2
2
|
<style>
|
|
3
3
|
*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#122f38;color:#fff4da;font-family:ui-rounded,system-ui,sans-serif}canvas{position:fixed;inset:0;width:100%;height:100%}main{position:fixed;inset:0;padding:clamp(22px,5vw,72px);pointer-events:none;display:flex;flex-direction:column;justify-content:space-between}header{max-width:580px}.eyebrow{font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:#b7d8ce}h1{font-size:clamp(46px,7.5vw,108px);line-height:.94;letter-spacing:-.06em;margin:18px 0}p{line-height:1.5;max-width:31em;color:#cce0d4}.panel{pointer-events:auto;background:#fff4da;color:#163b40;width:min(390px,100%);border-radius:24px;padding:22px;box-shadow:0 18px 70px #061e3244;transform:translateY(0);transition:transform .7s,background .7s}.panel.called{background:#c6efb3;transform:translateY(-10px)}.number{font-size:clamp(34px,5vw,60px);font-weight:750;letter-spacing:-.05em}button{border:0;min-height:50px;border-radius:14px;padding:12px 20px;font:700 16px system-ui;background:#163b40;color:#fff4da;cursor:pointer;width:100%;touch-action:manipulation}button:disabled{opacity:.45;cursor:default}button:focus-visible{outline:3px solid #ffb655;outline-offset:4px}.panel p{color:#49655f;font-size:13px;margin:10px 0}.line{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.tag{font:600 12px system-ui;text-transform:uppercase;letter-spacing:.1em}#ticket-qr{width:80px;height:80px;float:right;margin:0 0 6px 14px}#ticket-qr:empty{display:none}#ticket-qr svg{width:100%;height:100%}.live{display:flex;gap:8px;align-items:center;font-size:12px}.dot{width:7px;height:7px;background:#c6efb3;border-radius:50%}#status{min-height:18px;font-size:12px}body.wall .panel{width:min(510px,45vw)}body.wall header{max-width:800px}body.staff .panel{width:420px}footer{position:absolute;right:35px;bottom:24px;font-size:10px;letter-spacing:.15em;color:#9fbbb4}@media(max-width:600px){main{padding:78px 22px 26px}h1{font-size:48px}header p{font-size:13px;max-width:245px}.panel{padding:18px}footer{display:none}}@media(max-height:520px){main{flex-direction:row;align-items:center;padding:60px 24px 20px;gap:30px}h1{font-size:40px}.panel{max-width:310px}header p{display:none}}@media(prefers-reduced-motion:reduce){*{transition:none!important}}
|
|
4
|
-
</style></head><body><canvas aria-hidden="true"></canvas><main><header><div class="eyebrow">A space for everyone</div><h1>Good things<br>start here.</h1><p id="intro">Take a little moment. We’ll keep your place while you settle in.</p><div class="live"><i class="dot"></i><span id="connection">Joining the room…</span></div></header><section class="panel" aria-label="Your place"><div id="ticket-qr"></div><div class="line"><span class="tag" id="panel-label">Welcome in</span><span><b data-count>0</b> waiting
|
|
4
|
+
</style></head><body><canvas aria-hidden="true"></canvas><main><header><div class="eyebrow">A space for everyone</div><h1>Good things<br>start here.</h1><p id="intro">Take a little moment. We’ll keep your place while you settle in.</p><div class="live"><i class="dot"></i><span id="connection">Joining the room…</span></div></header><section class="panel" aria-label="Your place"><div id="ticket-qr"></div><div class="line"><span class="tag" id="panel-label">Welcome in</span><span><b data-count>0</b> <span id="waiting-word"></span></span></div><div class="number" id="ticket">Make yourself at home.</div><p id="detail">No account needed. Just a place in line.</p><button data-join>Join the queue</button><button data-next hidden>Call the next person</button><div id="status" role="status" aria-live="polite"></div></section><footer>ONE PLACE · TOGETHER</footer></main><script type="module" src="./assets/main.js"></script></body></html>
|