@weft-js/create 0.1.0 → 0.1.2
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/README.md +60 -10
- package/bin/create-weft.mjs +62 -14
- package/package.json +26 -16
- package/scripts/tarballs.mjs +56 -6
- package/src/data.mjs +90 -18
- package/src/index.mjs +210 -51
- package/src/stack/pages.mjs +151 -0
- package/src/stack/schema.mjs +195 -0
- package/src/stack.mjs +102 -313
- package/src/templates.mjs +174 -0
- package/src/wizard.mjs +65 -16
- package/templates/admin/files/drizzle/.schema-hash +1 -0
- package/templates/admin/files/drizzle/0000_admin.sql +136 -0
- package/templates/admin/files/drizzle/meta/0000_snapshot.json +771 -0
- package/templates/admin/files/drizzle/meta/_journal.json +13 -0
- package/templates/admin/files/public/favicon.svg +1 -0
- package/templates/admin/files/src/auth.server.ts +24 -0
- package/templates/admin/files/src/components/AuthPage.tsx +33 -0
- package/templates/admin/files/src/components/Failed.tsx +19 -0
- package/templates/admin/files/src/components/Logo.tsx +16 -0
- package/templates/admin/files/src/components/OrderDetail.tsx +93 -0
- package/templates/admin/files/src/components/OrdersFilters.tsx +44 -0
- package/templates/admin/files/src/components/OrdersTable.tsx +144 -0
- package/templates/admin/files/src/components/Overview.tsx +113 -0
- package/templates/admin/files/src/components/StatusBadge.tsx +12 -0
- package/templates/admin/files/src/db/schema.ts +68 -0
- package/templates/admin/files/src/lib/orders.ts +60 -0
- package/templates/admin/files/src/routes/404.tsx +20 -0
- package/templates/admin/files/src/routes/_error.tsx +20 -0
- package/templates/admin/files/src/routes/_layout.tsx +6 -0
- package/templates/admin/files/src/routes/admin/_layout.tsx +89 -0
- package/templates/admin/files/src/routes/admin/index.tsx +45 -0
- package/templates/admin/files/src/routes/admin/nested/_layout.tsx +35 -0
- package/templates/admin/files/src/routes/admin/nested/index.tsx +77 -0
- package/templates/admin/files/src/routes/admin/orders/[id].tsx +24 -0
- package/templates/admin/files/src/routes/admin/orders/index.tsx +28 -0
- package/templates/admin/files/src/routes/admin/per-row.tsx +81 -0
- package/templates/admin/files/src/routes/admin/slow.tsx +74 -0
- package/templates/admin/files/src/routes/index.tsx +13 -0
- package/templates/admin/files/src/routes/login.tsx +53 -0
- package/templates/admin/files/src/routes/signup.tsx +60 -0
- package/templates/admin/files/src/server/orders.ts +193 -0
- package/templates/admin/files/src/server/reports.ts +92 -0
- package/templates/admin/files/src/server/session.ts +42 -0
- package/templates/admin/files/src/theme.css +43 -0
- package/templates/admin/files/weft.config.ts +31 -0
- package/templates/admin/template.json +22 -0
- package/templates/catalog/files/drizzle/.schema-hash +1 -0
- package/templates/catalog/files/drizzle/0000_catalog.sql +47 -0
- package/templates/catalog/files/drizzle/meta/0000_snapshot.json +263 -0
- package/templates/catalog/files/drizzle/meta/_journal.json +13 -0
- package/templates/catalog/files/public/favicon.svg +1 -0
- package/templates/catalog/files/seed.sql +13 -0
- package/templates/catalog/files/src/components/AddToCart.tsx +22 -0
- package/templates/catalog/files/src/components/Aisles.tsx +31 -0
- package/templates/catalog/files/src/components/CartLines.tsx +96 -0
- package/templates/catalog/files/src/components/CartLink.tsx +29 -0
- package/templates/catalog/files/src/components/Failed.tsx +19 -0
- package/templates/catalog/files/src/components/Featured.tsx +19 -0
- package/templates/catalog/files/src/components/Filters.tsx +40 -0
- package/templates/catalog/files/src/components/Logo.tsx +16 -0
- package/templates/catalog/files/src/components/ProductArt.tsx +42 -0
- package/templates/catalog/files/src/components/ProductCard.tsx +42 -0
- package/templates/catalog/files/src/components/ProductList.tsx +65 -0
- package/templates/catalog/files/src/components/Related.tsx +19 -0
- package/templates/catalog/files/src/components/SiteFooter.tsx +9 -0
- package/templates/catalog/files/src/components/SiteHeader.tsx +16 -0
- package/templates/catalog/files/src/db/schema.ts +53 -0
- package/templates/catalog/files/src/lib/catalog.ts +35 -0
- package/templates/catalog/files/src/routes/404.tsx +18 -0
- package/templates/catalog/files/src/routes/_error.tsx +18 -0
- package/templates/catalog/files/src/routes/_layout.tsx +14 -0
- package/templates/catalog/files/src/routes/c/[slug].tsx +53 -0
- package/templates/catalog/files/src/routes/cart.tsx +29 -0
- package/templates/catalog/files/src/routes/index.tsx +84 -0
- package/templates/catalog/files/src/routes/p/[slug].tsx +67 -0
- package/templates/catalog/files/src/server/cart.ts +125 -0
- package/templates/catalog/files/src/server/catalog.ts +107 -0
- package/templates/catalog/files/src/theme.css +159 -0
- package/templates/catalog/files/weft.config.ts +30 -0
- package/templates/catalog/template.json +20 -0
- package/templates/landing/files/.weft/plugins.lock.json +42 -0
- package/templates/landing/files/assets/fonts/LICENSE-figtree.txt +93 -0
- package/templates/landing/files/assets/fonts/figtree.woff2 +0 -0
- package/templates/landing/files/assets/hero.webp +0 -0
- package/templates/landing/files/drizzle/.schema-hash +1 -0
- package/templates/landing/files/drizzle/0000_landing.sql +6 -0
- package/templates/landing/files/drizzle/meta/0000_snapshot.json +60 -0
- package/templates/landing/files/drizzle/meta/_journal.json +13 -0
- package/templates/landing/files/public/favicon.svg +1 -0
- package/templates/landing/files/src/components/SiteFooter.tsx +24 -0
- package/templates/landing/files/src/components/SiteHeader.tsx +23 -0
- package/templates/landing/files/src/components/WaitlistForm.tsx +36 -0
- package/templates/landing/files/src/db/schema.ts +7 -0
- package/templates/landing/files/src/routes/404.tsx +24 -0
- package/templates/landing/files/src/routes/_error.tsx +24 -0
- package/templates/landing/files/src/routes/_layout.tsx +14 -0
- package/templates/landing/files/src/routes/index.tsx +130 -0
- package/templates/landing/files/src/routes/joined.tsx +30 -0
- package/templates/landing/files/src/routes/pricing.tsx +131 -0
- package/templates/landing/files/src/routes/privacy.tsx +56 -0
- package/templates/landing/files/src/server/waitlist.ts +13 -0
- package/templates/landing/files/src/theme.css +108 -0
- package/templates/landing/files/weft.config.ts +44 -0
- package/templates/landing/template.json +17 -0
- package/templates/todo/files/drizzle/.schema-hash +1 -0
- package/templates/todo/files/drizzle/0000_todo.sql +82 -0
- package/templates/todo/files/drizzle/meta/0000_snapshot.json +462 -0
- package/templates/todo/files/drizzle/meta/_journal.json +13 -0
- package/templates/todo/files/public/favicon.svg +1 -0
- package/templates/todo/files/src/components/AuthPage.tsx +33 -0
- package/templates/todo/files/src/components/Logo.tsx +14 -0
- package/templates/todo/files/src/components/NewTodo.tsx +27 -0
- package/templates/todo/files/src/components/TodoList.tsx +101 -0
- package/templates/todo/files/src/db/schema.ts +18 -0
- package/templates/todo/files/src/lib/views.ts +13 -0
- package/templates/todo/files/src/routes/404.tsx +20 -0
- package/templates/todo/files/src/routes/_error.tsx +20 -0
- package/templates/todo/files/src/routes/_layout.tsx +6 -0
- package/templates/todo/files/src/routes/index.tsx +13 -0
- package/templates/todo/files/src/routes/login.tsx +53 -0
- package/templates/todo/files/src/routes/signup.tsx +57 -0
- package/templates/todo/files/src/routes/todos.tsx +87 -0
- package/templates/todo/files/src/server/todos.ts +98 -0
- package/templates/todo/files/src/theme.css +71 -0
- package/templates/todo/files/weft.config.ts +30 -0
- package/templates/todo/template.json +18 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "ef9a2192-3d77-49a8-a83b-69fc723129b0",
|
|
3
|
+
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
4
|
+
"version": "7",
|
|
5
|
+
"dialect": "postgresql",
|
|
6
|
+
"tables": {
|
|
7
|
+
"public.cart_items": {
|
|
8
|
+
"name": "cart_items",
|
|
9
|
+
"schema": "",
|
|
10
|
+
"columns": {
|
|
11
|
+
"cart_id": {
|
|
12
|
+
"name": "cart_id",
|
|
13
|
+
"type": "uuid",
|
|
14
|
+
"primaryKey": false,
|
|
15
|
+
"notNull": true
|
|
16
|
+
},
|
|
17
|
+
"product_id": {
|
|
18
|
+
"name": "product_id",
|
|
19
|
+
"type": "integer",
|
|
20
|
+
"primaryKey": false,
|
|
21
|
+
"notNull": true
|
|
22
|
+
},
|
|
23
|
+
"quantity": {
|
|
24
|
+
"name": "quantity",
|
|
25
|
+
"type": "integer",
|
|
26
|
+
"primaryKey": false,
|
|
27
|
+
"notNull": true
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"indexes": {},
|
|
31
|
+
"foreignKeys": {
|
|
32
|
+
"cart_items_cart_id_carts_id_fk": {
|
|
33
|
+
"name": "cart_items_cart_id_carts_id_fk",
|
|
34
|
+
"tableFrom": "cart_items",
|
|
35
|
+
"tableTo": "carts",
|
|
36
|
+
"columnsFrom": [
|
|
37
|
+
"cart_id"
|
|
38
|
+
],
|
|
39
|
+
"columnsTo": [
|
|
40
|
+
"id"
|
|
41
|
+
],
|
|
42
|
+
"onDelete": "cascade",
|
|
43
|
+
"onUpdate": "no action"
|
|
44
|
+
},
|
|
45
|
+
"cart_items_product_id_products_id_fk": {
|
|
46
|
+
"name": "cart_items_product_id_products_id_fk",
|
|
47
|
+
"tableFrom": "cart_items",
|
|
48
|
+
"tableTo": "products",
|
|
49
|
+
"columnsFrom": [
|
|
50
|
+
"product_id"
|
|
51
|
+
],
|
|
52
|
+
"columnsTo": [
|
|
53
|
+
"id"
|
|
54
|
+
],
|
|
55
|
+
"onDelete": "no action",
|
|
56
|
+
"onUpdate": "no action"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"compositePrimaryKeys": {
|
|
60
|
+
"cart_items_cart_id_product_id_pk": {
|
|
61
|
+
"name": "cart_items_cart_id_product_id_pk",
|
|
62
|
+
"columns": [
|
|
63
|
+
"cart_id",
|
|
64
|
+
"product_id"
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"uniqueConstraints": {},
|
|
69
|
+
"policies": {},
|
|
70
|
+
"checkConstraints": {},
|
|
71
|
+
"isRLSEnabled": false
|
|
72
|
+
},
|
|
73
|
+
"public.carts": {
|
|
74
|
+
"name": "carts",
|
|
75
|
+
"schema": "",
|
|
76
|
+
"columns": {
|
|
77
|
+
"id": {
|
|
78
|
+
"name": "id",
|
|
79
|
+
"type": "uuid",
|
|
80
|
+
"primaryKey": true,
|
|
81
|
+
"notNull": true,
|
|
82
|
+
"default": "gen_random_uuid()"
|
|
83
|
+
},
|
|
84
|
+
"created_at": {
|
|
85
|
+
"name": "created_at",
|
|
86
|
+
"type": "timestamp with time zone",
|
|
87
|
+
"primaryKey": false,
|
|
88
|
+
"notNull": true,
|
|
89
|
+
"default": "now()"
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"indexes": {},
|
|
93
|
+
"foreignKeys": {},
|
|
94
|
+
"compositePrimaryKeys": {},
|
|
95
|
+
"uniqueConstraints": {},
|
|
96
|
+
"policies": {},
|
|
97
|
+
"checkConstraints": {},
|
|
98
|
+
"isRLSEnabled": false
|
|
99
|
+
},
|
|
100
|
+
"public.categories": {
|
|
101
|
+
"name": "categories",
|
|
102
|
+
"schema": "",
|
|
103
|
+
"columns": {
|
|
104
|
+
"id": {
|
|
105
|
+
"name": "id",
|
|
106
|
+
"type": "serial",
|
|
107
|
+
"primaryKey": true,
|
|
108
|
+
"notNull": true
|
|
109
|
+
},
|
|
110
|
+
"slug": {
|
|
111
|
+
"name": "slug",
|
|
112
|
+
"type": "text",
|
|
113
|
+
"primaryKey": false,
|
|
114
|
+
"notNull": true
|
|
115
|
+
},
|
|
116
|
+
"name": {
|
|
117
|
+
"name": "name",
|
|
118
|
+
"type": "text",
|
|
119
|
+
"primaryKey": false,
|
|
120
|
+
"notNull": true
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"indexes": {},
|
|
124
|
+
"foreignKeys": {},
|
|
125
|
+
"compositePrimaryKeys": {},
|
|
126
|
+
"uniqueConstraints": {
|
|
127
|
+
"categories_slug_unique": {
|
|
128
|
+
"name": "categories_slug_unique",
|
|
129
|
+
"nullsNotDistinct": false,
|
|
130
|
+
"columns": [
|
|
131
|
+
"slug"
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"policies": {},
|
|
136
|
+
"checkConstraints": {},
|
|
137
|
+
"isRLSEnabled": false
|
|
138
|
+
},
|
|
139
|
+
"public.products": {
|
|
140
|
+
"name": "products",
|
|
141
|
+
"schema": "",
|
|
142
|
+
"columns": {
|
|
143
|
+
"id": {
|
|
144
|
+
"name": "id",
|
|
145
|
+
"type": "serial",
|
|
146
|
+
"primaryKey": true,
|
|
147
|
+
"notNull": true
|
|
148
|
+
},
|
|
149
|
+
"slug": {
|
|
150
|
+
"name": "slug",
|
|
151
|
+
"type": "text",
|
|
152
|
+
"primaryKey": false,
|
|
153
|
+
"notNull": true
|
|
154
|
+
},
|
|
155
|
+
"name": {
|
|
156
|
+
"name": "name",
|
|
157
|
+
"type": "text",
|
|
158
|
+
"primaryKey": false,
|
|
159
|
+
"notNull": true
|
|
160
|
+
},
|
|
161
|
+
"description": {
|
|
162
|
+
"name": "description",
|
|
163
|
+
"type": "text",
|
|
164
|
+
"primaryKey": false,
|
|
165
|
+
"notNull": true
|
|
166
|
+
},
|
|
167
|
+
"category_id": {
|
|
168
|
+
"name": "category_id",
|
|
169
|
+
"type": "integer",
|
|
170
|
+
"primaryKey": false,
|
|
171
|
+
"notNull": true
|
|
172
|
+
},
|
|
173
|
+
"price_cents": {
|
|
174
|
+
"name": "price_cents",
|
|
175
|
+
"type": "integer",
|
|
176
|
+
"primaryKey": false,
|
|
177
|
+
"notNull": true
|
|
178
|
+
},
|
|
179
|
+
"stock": {
|
|
180
|
+
"name": "stock",
|
|
181
|
+
"type": "integer",
|
|
182
|
+
"primaryKey": false,
|
|
183
|
+
"notNull": true
|
|
184
|
+
},
|
|
185
|
+
"featured": {
|
|
186
|
+
"name": "featured",
|
|
187
|
+
"type": "boolean",
|
|
188
|
+
"primaryKey": false,
|
|
189
|
+
"notNull": true,
|
|
190
|
+
"default": false
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"indexes": {
|
|
194
|
+
"products_category": {
|
|
195
|
+
"name": "products_category",
|
|
196
|
+
"columns": [
|
|
197
|
+
{
|
|
198
|
+
"expression": "category_id",
|
|
199
|
+
"isExpression": false,
|
|
200
|
+
"asc": true,
|
|
201
|
+
"nulls": "last"
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"expression": "price_cents",
|
|
205
|
+
"isExpression": false,
|
|
206
|
+
"asc": true,
|
|
207
|
+
"nulls": "last"
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"expression": "id",
|
|
211
|
+
"isExpression": false,
|
|
212
|
+
"asc": true,
|
|
213
|
+
"nulls": "last"
|
|
214
|
+
}
|
|
215
|
+
],
|
|
216
|
+
"isUnique": false,
|
|
217
|
+
"concurrently": false,
|
|
218
|
+
"method": "btree",
|
|
219
|
+
"with": {}
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
"foreignKeys": {
|
|
223
|
+
"products_category_id_categories_id_fk": {
|
|
224
|
+
"name": "products_category_id_categories_id_fk",
|
|
225
|
+
"tableFrom": "products",
|
|
226
|
+
"tableTo": "categories",
|
|
227
|
+
"columnsFrom": [
|
|
228
|
+
"category_id"
|
|
229
|
+
],
|
|
230
|
+
"columnsTo": [
|
|
231
|
+
"id"
|
|
232
|
+
],
|
|
233
|
+
"onDelete": "no action",
|
|
234
|
+
"onUpdate": "no action"
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
"compositePrimaryKeys": {},
|
|
238
|
+
"uniqueConstraints": {
|
|
239
|
+
"products_slug_unique": {
|
|
240
|
+
"name": "products_slug_unique",
|
|
241
|
+
"nullsNotDistinct": false,
|
|
242
|
+
"columns": [
|
|
243
|
+
"slug"
|
|
244
|
+
]
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
"policies": {},
|
|
248
|
+
"checkConstraints": {},
|
|
249
|
+
"isRLSEnabled": false
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
"enums": {},
|
|
253
|
+
"schemas": {},
|
|
254
|
+
"sequences": {},
|
|
255
|
+
"roles": {},
|
|
256
|
+
"policies": {},
|
|
257
|
+
"views": {},
|
|
258
|
+
"_meta": {
|
|
259
|
+
"columns": {},
|
|
260
|
+
"schemas": {},
|
|
261
|
+
"tables": {}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="16" fill="#8a2435"/><path d="M8 23V10l8 8 8-8v13" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- Sample goods, so a new shop has something on its shelves: psql "$DATABASE_URL" -f seed.sql
|
|
2
|
+
insert into categories (slug, name)
|
|
3
|
+
select lower(n), n from unnest(array['Kitchen', 'Garden', 'Office', 'Outdoor', 'Audio', 'Lighting', 'Travel', 'Bath']) as n
|
|
4
|
+
on conflict do nothing;
|
|
5
|
+
|
|
6
|
+
insert into products (slug, name, description, category_id, price_cents, stock, featured)
|
|
7
|
+
select 'product-' || g,
|
|
8
|
+
(array['Oak', 'Linen', 'Copper', 'Slate', 'Walnut', 'Cedar', 'Ceramic', 'Brass'])[1 + (g % 8)] || ' ' ||
|
|
9
|
+
(array['Lamp', 'Stool', 'Kettle', 'Shelf', 'Basket', 'Speaker', 'Tray', 'Planter', 'Satchel', 'Mug'])[1 + (g % 10)] || ' ' || g,
|
|
10
|
+
'A dependable piece, made to be used every day and repaired when it wears.',
|
|
11
|
+
(select id from categories order by id offset g % 8 limit 1), 500 + (g * 7919) % 49500, (g * 31) % 40, g % 20 = 0
|
|
12
|
+
from generate_series(1, 400) g
|
|
13
|
+
on conflict do nothing;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Form, type FormApi } from '@weft-js/start'
|
|
2
|
+
import { Button, Stack } from '@weft-js/warp'
|
|
3
|
+
import { addToCart } from '#/server/cart'
|
|
4
|
+
|
|
5
|
+
export function AddToCart({ slug, soldOut }: { slug: string; soldOut: boolean }) {
|
|
6
|
+
return (
|
|
7
|
+
<Form of={addToCart} reset={false}>
|
|
8
|
+
{(form: FormApi) => (
|
|
9
|
+
<Stack gap="tight">
|
|
10
|
+
<input type="hidden" name="slug" value={slug} />
|
|
11
|
+
<Button type="submit" size="lg" full disabled={soldOut} loading={form.pending.value}>
|
|
12
|
+
Add to cart
|
|
13
|
+
</Button>
|
|
14
|
+
<form.Error name="slug" class="form-error" />
|
|
15
|
+
<p class="added" role="status">
|
|
16
|
+
{form.result.value ? 'In your cart.' : ''}
|
|
17
|
+
</p>
|
|
18
|
+
</Stack>
|
|
19
|
+
)}
|
|
20
|
+
</Form>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Link } from '@weft-js/start'
|
|
2
|
+
import { Skeleton, Text } from '@weft-js/warp'
|
|
3
|
+
import { listCategories } from '#/server/catalog'
|
|
4
|
+
|
|
5
|
+
export function Aisles({ current }: { current?: string }) {
|
|
6
|
+
const aisles = listCategories()
|
|
7
|
+
return (
|
|
8
|
+
<>
|
|
9
|
+
{aisles.value?.length === 0 && (
|
|
10
|
+
<Text subtle>The aisles are being set up. Check back soon.</Text>
|
|
11
|
+
)}
|
|
12
|
+
<nav class="aisles" aria-label="Aisles" data-compact={current !== undefined ? '' : undefined}>
|
|
13
|
+
{aisles.value?.map((aisle) => (
|
|
14
|
+
<Link
|
|
15
|
+
key={aisle.slug}
|
|
16
|
+
href={`/c/${aisle.slug}`}
|
|
17
|
+
aria-current={aisle.slug === current ? 'page' : undefined}
|
|
18
|
+
>
|
|
19
|
+
{aisle.name}
|
|
20
|
+
</Link>
|
|
21
|
+
))}
|
|
22
|
+
</nav>
|
|
23
|
+
</>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const AislesLoading = () => (
|
|
28
|
+
<div role="status" aria-label="Loading aisles">
|
|
29
|
+
<Skeleton height="3rem" />
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Link } from '@weft-js/start'
|
|
2
|
+
import { Button, Heading, Skeleton, Stack, Text, toast } from '@weft-js/warp'
|
|
3
|
+
import { derived } from '@weft-js/core'
|
|
4
|
+
import { ProductArt } from '#/components/ProductArt'
|
|
5
|
+
import { money } from '#/lib/catalog'
|
|
6
|
+
import { listCart, setQuantity } from '#/server/cart'
|
|
7
|
+
|
|
8
|
+
const unsaved = () =>
|
|
9
|
+
toast.show({
|
|
10
|
+
tone: 'critical',
|
|
11
|
+
title: "That didn't save",
|
|
12
|
+
description: 'Your cart is back as it was. Try again in a moment.',
|
|
13
|
+
})
|
|
14
|
+
const change = (slug: string, quantity: number) => setQuantity({ slug, quantity }).catch(unsaved)
|
|
15
|
+
|
|
16
|
+
export function CartLines() {
|
|
17
|
+
const cart = listCart()
|
|
18
|
+
const lines = derived(() => cart.value ?? [])
|
|
19
|
+
const subtotal = derived(() =>
|
|
20
|
+
lines.value.reduce((sum, line) => sum + line.priceCents * line.quantity, 0),
|
|
21
|
+
)
|
|
22
|
+
// A real element at the root: under a bare fragment hydration misses every key and leaves bare elements with no handlers.
|
|
23
|
+
return (
|
|
24
|
+
<div>
|
|
25
|
+
{lines.value.length === 0 ? (
|
|
26
|
+
<Stack gap="tight" class="blank">
|
|
27
|
+
<Heading level={2}>Your cart is empty</Heading>
|
|
28
|
+
<Text subtle>Everything you add stays here for sixty days.</Text>
|
|
29
|
+
<Link href="/">Back to the aisles</Link>
|
|
30
|
+
</Stack>
|
|
31
|
+
) : (
|
|
32
|
+
<Stack gap="group">
|
|
33
|
+
<ul class="lines" aria-label="Cart">
|
|
34
|
+
{lines.value.map((line) => (
|
|
35
|
+
<li key={line.slug} class="line">
|
|
36
|
+
<ProductArt name={line.name} />
|
|
37
|
+
<div>
|
|
38
|
+
<Link href={`/p/${line.slug}`}>{line.name}</Link>
|
|
39
|
+
<Text subtle>{money(line.priceCents)} each</Text>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="stepper">
|
|
42
|
+
<Button
|
|
43
|
+
emphasis="outline"
|
|
44
|
+
tone="neutral"
|
|
45
|
+
size="sm"
|
|
46
|
+
label={`One fewer ${line.name}`}
|
|
47
|
+
onClick={() => change(line.slug, line.quantity - 1)}
|
|
48
|
+
>
|
|
49
|
+
−
|
|
50
|
+
</Button>
|
|
51
|
+
<span>
|
|
52
|
+
<span data-part="sr-only">Quantity of {line.name}: </span>
|
|
53
|
+
{line.quantity}
|
|
54
|
+
</span>
|
|
55
|
+
<Button
|
|
56
|
+
emphasis="outline"
|
|
57
|
+
tone="neutral"
|
|
58
|
+
size="sm"
|
|
59
|
+
label={`One more ${line.name}`}
|
|
60
|
+
disabled={line.quantity >= Math.min(line.stock, 99)}
|
|
61
|
+
onClick={() => change(line.slug, line.quantity + 1)}
|
|
62
|
+
>
|
|
63
|
+
+
|
|
64
|
+
</Button>
|
|
65
|
+
</div>
|
|
66
|
+
<span class="price">{money(line.priceCents * line.quantity)}</span>
|
|
67
|
+
<Button
|
|
68
|
+
emphasis="ghost"
|
|
69
|
+
tone="neutral"
|
|
70
|
+
size="sm"
|
|
71
|
+
onClick={() => change(line.slug, 0)}
|
|
72
|
+
aria-label={`Remove ${line.name}`}
|
|
73
|
+
>
|
|
74
|
+
Remove
|
|
75
|
+
</Button>
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
<div class="total">
|
|
80
|
+
<Text subtle>Subtotal</Text>
|
|
81
|
+
<span class="price">{money(subtotal.value)}</span>
|
|
82
|
+
</div>
|
|
83
|
+
<Text subtle>Checkout opens soon. Your cart keeps until then.</Text>
|
|
84
|
+
</Stack>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const CartLoading = () => (
|
|
91
|
+
<div role="status" aria-label="Loading your cart">
|
|
92
|
+
<Stack gap="related">
|
|
93
|
+
<Skeleton lines={4} height="4rem" />
|
|
94
|
+
</Stack>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Link } from '@weft-js/start'
|
|
2
|
+
import { Boundary } from '@weft-js/core'
|
|
3
|
+
import { cartCount } from '#/server/cart'
|
|
4
|
+
|
|
5
|
+
function CartCount() {
|
|
6
|
+
const cart = cartCount()
|
|
7
|
+
return (
|
|
8
|
+
<Link href="/cart" class="cart-link">
|
|
9
|
+
Cart ({cart.value?.count ?? 0})
|
|
10
|
+
</Link>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Before the count lands, and if it never does, the way to the cart still works. */
|
|
15
|
+
const CartOnly = () => (
|
|
16
|
+
<Link href="/cart" class="cart-link">
|
|
17
|
+
Cart
|
|
18
|
+
</Link>
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// Its own component, so the island is this Boundary: the compiler then sees the count and runs it as the page shows, where
|
|
22
|
+
// from inside the header's <Center> it would wait for a click, and "Add to cart" couldn't reach it.
|
|
23
|
+
export function CartLink() {
|
|
24
|
+
return (
|
|
25
|
+
<Boundary pending={<CartOnly />} error={() => <CartOnly />}>
|
|
26
|
+
<CartCount />
|
|
27
|
+
</Boundary>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Alert, Button } from '@weft-js/warp'
|
|
2
|
+
|
|
3
|
+
/** What a Boundary shows when its data didn't arrive. */
|
|
4
|
+
export function Failed({ what, retry }: { what: string; retry: () => void }) {
|
|
5
|
+
return (
|
|
6
|
+
<Alert
|
|
7
|
+
tone="critical"
|
|
8
|
+
title={`${what} didn't load`}
|
|
9
|
+
live
|
|
10
|
+
actions={
|
|
11
|
+
<Button emphasis="outline" onClick={retry}>
|
|
12
|
+
Try again
|
|
13
|
+
</Button>
|
|
14
|
+
}
|
|
15
|
+
>
|
|
16
|
+
The shop is still here. The connection to the shelves dropped for a moment.
|
|
17
|
+
</Alert>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Grid, Text } from '@weft-js/warp'
|
|
2
|
+
import { ProductCard } from '#/components/ProductCard'
|
|
3
|
+
import { featuredProducts } from '#/server/catalog'
|
|
4
|
+
|
|
5
|
+
export function Featured() {
|
|
6
|
+
const featured = featuredProducts()
|
|
7
|
+
return (
|
|
8
|
+
<>
|
|
9
|
+
{featured.value?.length === 0 && (
|
|
10
|
+
<Text subtle>Nothing's on the front table right now. The aisles above are still open.</Text>
|
|
11
|
+
)}
|
|
12
|
+
<Grid min="xs" cols={4} gap="group">
|
|
13
|
+
{featured.value?.map((product) => (
|
|
14
|
+
<ProductCard key={product.slug} product={product} />
|
|
15
|
+
))}
|
|
16
|
+
</Grid>
|
|
17
|
+
</>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { navigate } from '@weft-js/start'
|
|
2
|
+
import { Button, Checkbox, NumberField, Stack } from '@weft-js/warp'
|
|
3
|
+
import { amount, viewHref, type View } from '#/lib/catalog'
|
|
4
|
+
|
|
5
|
+
/** A real GET form, so filters work before the page has hydrated. */
|
|
6
|
+
export function Filters({ slug, view }: { slug: string; view: View }) {
|
|
7
|
+
const apply = (e: SubmitEvent) => {
|
|
8
|
+
e.preventDefault()
|
|
9
|
+
const data = new FormData(e.currentTarget as HTMLFormElement)
|
|
10
|
+
navigate(
|
|
11
|
+
viewHref(slug, {
|
|
12
|
+
min: amount(data.get('min')),
|
|
13
|
+
max: amount(data.get('max')),
|
|
14
|
+
stock: data.has('stock'),
|
|
15
|
+
page: 1,
|
|
16
|
+
}),
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
return (
|
|
20
|
+
<form class="filters" method="get" action={`/c/${slug}`} onSubmit={apply}>
|
|
21
|
+
<Stack gap="related">
|
|
22
|
+
<div class="price-range">
|
|
23
|
+
<NumberField label="Min price" name="min" min={0} prefix="$" defaultValue={view.min} />
|
|
24
|
+
<NumberField label="Max price" name="max" min={0} prefix="$" defaultValue={view.max} />
|
|
25
|
+
</div>
|
|
26
|
+
<Checkbox
|
|
27
|
+
name="stock"
|
|
28
|
+
value="1"
|
|
29
|
+
checked={view.stock}
|
|
30
|
+
onChange={(e: Event) => (e.currentTarget as HTMLInputElement).form!.requestSubmit()}
|
|
31
|
+
>
|
|
32
|
+
In stock only
|
|
33
|
+
</Checkbox>
|
|
34
|
+
<Button type="submit" emphasis="outline" full>
|
|
35
|
+
Apply
|
|
36
|
+
</Button>
|
|
37
|
+
</Stack>
|
|
38
|
+
</form>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Mendwell's mark: an M stitched with one line. */
|
|
2
|
+
export function Logo() {
|
|
3
|
+
return (
|
|
4
|
+
<svg class="logo" viewBox="0 0 32 32" aria-hidden="true">
|
|
5
|
+
<circle cx="16" cy="16" r="16" fill="var(--warp-color-accent-solid)" />
|
|
6
|
+
<path
|
|
7
|
+
d="M8 23V10l8 8 8-8v13"
|
|
8
|
+
fill="none"
|
|
9
|
+
stroke="var(--warp-color-accent-on-solid)"
|
|
10
|
+
stroke-width="3"
|
|
11
|
+
stroke-linecap="round"
|
|
12
|
+
stroke-linejoin="round"
|
|
13
|
+
/>
|
|
14
|
+
</svg>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// There are no product photos yet, so each product draws itself: the object in its name, in its material's colour.
|
|
2
|
+
// Swap this for an `<Image>` when real photography arrives.
|
|
3
|
+
const objects: Record<string, string> = {
|
|
4
|
+
lamp: 'M44 22h32l12 34H32zM57 56h6v36h-6zM42 92h36a4 4 0 0 1 4 4v2H38v-2a4 4 0 0 1 4-4z',
|
|
5
|
+
stool:
|
|
6
|
+
'M30 34h60a6 6 0 0 1 0 12H30a6 6 0 0 1 0-12zM36 46h9l-7 54h-8zM75 46h9l7 54h-8zM42 70h36v6H42z',
|
|
7
|
+
kettle:
|
|
8
|
+
'M38 48h44c6 12 10 24 10 38a10 10 0 0 1-10 10H38a10 10 0 0 1-10-10c0-14 4-26 10-38zM52 38h16v10H52zM44 40c0-22 32-22 32 0h-6c0-14-20-14-20 0zM28 60 12 50l-2 6 14 16z',
|
|
9
|
+
shelf:
|
|
10
|
+
'M22 30h76v6H22zM22 60h76v6H22zM22 90h76v6H22zM30 22h6v8h-6zM30 36h6v24h-6zM30 66h6v24h-6zM30 96h6v8h-6zM84 22h6v8h-6zM84 36h6v24h-6zM84 66h6v24h-6zM84 96h6v8h-6z',
|
|
11
|
+
basket:
|
|
12
|
+
'M24 56h72l-8 42a6 6 0 0 1-6 5H38a6 6 0 0 1-6-5zM34 56c0-40 52-40 52 0h-7c0-30-38-30-38 0z',
|
|
13
|
+
speaker:
|
|
14
|
+
'M38 18h44a6 6 0 0 1 6 6v72a6 6 0 0 1-6 6H38a6 6 0 0 1-6-6V24a6 6 0 0 1 6-6zM60 30a8 8 0 1 0 0 16 8 8 0 0 0 0-16zm0 26a16 16 0 1 0 0 32 16 16 0 0 0 0-32z',
|
|
15
|
+
tray: 'M14 66h92l-6 16a6 6 0 0 1-6 4H26a6 6 0 0 1-6-4zM8 60h22v6H8zM90 60h22v6H90z',
|
|
16
|
+
planter:
|
|
17
|
+
'M34 62h52l-6 40a6 6 0 0 1-6 4H46a6 6 0 0 1-6-4zM57 62V38c-16 0-24-8-26-22 16 0 26 8 29 20 3-12 13-20 29-20-2 14-10 22-26 22v24z',
|
|
18
|
+
satchel:
|
|
19
|
+
'M26 42h68a6 6 0 0 1 6 6v46a6 6 0 0 1-6 6H26a6 6 0 0 1-6-6V48a6 6 0 0 1 6-6zM42 42c0-24 36-24 36 0h-7c0-14-22-14-22 0zM54 56h12v16H54z',
|
|
20
|
+
mug: 'M30 34h50v54a10 10 0 0 1-10 10H40a10 10 0 0 1-10-10zM80 46h8a14 14 0 0 1 0 28h-8v-7h8a7 7 0 0 0 0-14h-8z',
|
|
21
|
+
}
|
|
22
|
+
const box = 'M30 40h60v56H30zM24 30h72v10H24z'
|
|
23
|
+
|
|
24
|
+
const word = (name: string, among: readonly string[]) =>
|
|
25
|
+
name
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.split(/\s+/)
|
|
28
|
+
.find((w) => among.includes(w))
|
|
29
|
+
const materials = ['oak', 'linen', 'copper', 'slate', 'walnut', 'cedar', 'ceramic', 'brass']
|
|
30
|
+
const shapes = Object.keys(objects)
|
|
31
|
+
|
|
32
|
+
export function ProductArt({ name }: { name: string }) {
|
|
33
|
+
const material = () => word(name, materials) ?? 'plain'
|
|
34
|
+
const path = () => objects[word(name, shapes) ?? ''] ?? box
|
|
35
|
+
return (
|
|
36
|
+
<span class="art" data-material={material()}>
|
|
37
|
+
<svg viewBox="0 0 120 120" aria-hidden="true">
|
|
38
|
+
<path fill-rule="evenodd" d={path()} />
|
|
39
|
+
</svg>
|
|
40
|
+
</span>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Link } from '@weft-js/start'
|
|
2
|
+
import { Badge, Grid, Skeleton } from '@weft-js/warp'
|
|
3
|
+
import { ProductArt } from '#/components/ProductArt'
|
|
4
|
+
import { money } from '#/lib/catalog'
|
|
5
|
+
import type { ProductCardRow } from '#/server/catalog'
|
|
6
|
+
|
|
7
|
+
export function ProductCard({ product }: { product: ProductCardRow }) {
|
|
8
|
+
return (
|
|
9
|
+
<Link href={`/p/${product.slug}`} class="product">
|
|
10
|
+
<ProductArt name={product.name} />
|
|
11
|
+
<span class="product-name">{product.name}</span>
|
|
12
|
+
<span class="product-foot">
|
|
13
|
+
<span class="price">{money(product.priceCents)}</span>
|
|
14
|
+
{product.stock === 0 && <Badge tone="neutral">Sold out</Badge>}
|
|
15
|
+
</span>
|
|
16
|
+
</Link>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ProductGridLoading({
|
|
21
|
+
count,
|
|
22
|
+
cols,
|
|
23
|
+
label,
|
|
24
|
+
}: {
|
|
25
|
+
count: number
|
|
26
|
+
cols: 3 | 4
|
|
27
|
+
label: string
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<div role="status" aria-label={label}>
|
|
31
|
+
<Grid min="xs" cols={cols} gap="group">
|
|
32
|
+
{Array.from({ length: count }, () => (
|
|
33
|
+
<div class="product">
|
|
34
|
+
<Skeleton shape="block" height="auto" width="100%" />
|
|
35
|
+
<Skeleton />
|
|
36
|
+
<Skeleton width="30%" />
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
</Grid>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|