@weft-js/create 0.1.1 → 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 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#4f46e5"/><path d="M10 25V7h12v18" fill="none" stroke="#fff" stroke-width="2.5" stroke-linejoin="round"/><path d="M10 7l8 3v15h-8z" fill="#fff"/></svg>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createAuth } from '@weft-js/start/auth/better-auth'
|
|
2
|
+
import { drizzleAuth } from '@weft-js/start/auth/drizzle'
|
|
3
|
+
import { APIError } from 'better-auth/api'
|
|
4
|
+
import { db } from '#/db/index.server'
|
|
5
|
+
import { user } from './db/auth'
|
|
6
|
+
import * as schema from './db/schema'
|
|
7
|
+
import { env } from './env.server'
|
|
8
|
+
|
|
9
|
+
// Everyone signed in can see every order, so the first account is the owner and sign-up closes behind it.
|
|
10
|
+
const firstAccountOnly = async () => {
|
|
11
|
+
if (await db.$count(user))
|
|
12
|
+
throw new APIError('FORBIDDEN', {
|
|
13
|
+
message: 'This store already has an owner. Ask them to add you.',
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default createAuth({
|
|
18
|
+
db,
|
|
19
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
20
|
+
options: {
|
|
21
|
+
database: drizzleAuth(db, schema),
|
|
22
|
+
databaseHooks: { user: { create: { before: firstAccountOnly } } },
|
|
23
|
+
},
|
|
24
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AuthScreen, Heading, Stack, Text } from '@weft-js/warp'
|
|
2
|
+
import { Logo } from '#/components/Logo'
|
|
3
|
+
|
|
4
|
+
export function AuthPage({
|
|
5
|
+
title,
|
|
6
|
+
lede,
|
|
7
|
+
children,
|
|
8
|
+
}: {
|
|
9
|
+
title: string
|
|
10
|
+
lede: string
|
|
11
|
+
children?: unknown
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<AuthScreen
|
|
15
|
+
name="Backroom"
|
|
16
|
+
logo={<Logo />}
|
|
17
|
+
quote="Every order, every customer, one quiet room behind the shop."
|
|
18
|
+
>
|
|
19
|
+
<Stack gap="group">
|
|
20
|
+
<span class="auth-mark">
|
|
21
|
+
<Logo /> Backroom
|
|
22
|
+
</span>
|
|
23
|
+
<Stack gap="tight">
|
|
24
|
+
<Heading level={1} role="title">
|
|
25
|
+
{title}
|
|
26
|
+
</Heading>
|
|
27
|
+
<Text subtle>{lede}</Text>
|
|
28
|
+
</Stack>
|
|
29
|
+
{children}
|
|
30
|
+
</Stack>
|
|
31
|
+
</AuthScreen>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -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
|
+
Nothing's lost. The connection to the store dropped for a moment.
|
|
17
|
+
</Alert>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Backroom's mark: a door left ajar, with light coming through. */
|
|
2
|
+
export function Logo() {
|
|
3
|
+
return (
|
|
4
|
+
<svg class="logo" viewBox="0 0 32 32" aria-hidden="true">
|
|
5
|
+
<rect width="32" height="32" rx="8" fill="var(--warp-color-accent-solid)" />
|
|
6
|
+
<path
|
|
7
|
+
d="M10 25V7h12v18"
|
|
8
|
+
fill="none"
|
|
9
|
+
stroke="var(--warp-color-accent-on-solid)"
|
|
10
|
+
stroke-width="2.5"
|
|
11
|
+
stroke-linejoin="round"
|
|
12
|
+
/>
|
|
13
|
+
<path d="M10 7l8 3v15h-8z" fill="var(--warp-color-accent-on-solid)" />
|
|
14
|
+
</svg>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Card,
|
|
3
|
+
Grid,
|
|
4
|
+
Heading,
|
|
5
|
+
Select,
|
|
6
|
+
Skeleton,
|
|
7
|
+
Stack,
|
|
8
|
+
Table,
|
|
9
|
+
Text,
|
|
10
|
+
toast,
|
|
11
|
+
type Column,
|
|
12
|
+
} from '@weft-js/warp'
|
|
13
|
+
import { derived } from '@weft-js/core'
|
|
14
|
+
import { StatusBadge } from '#/components/StatusBadge'
|
|
15
|
+
import { date, money, statuses, type Status } from '#/lib/orders'
|
|
16
|
+
import { getOrder, setOrderStatus } from '#/server/orders'
|
|
17
|
+
|
|
18
|
+
type Item = { id: number; product: string; quantity: number; priceCents: number }
|
|
19
|
+
|
|
20
|
+
const options = statuses.map((status) => ({ value: status, label: status }))
|
|
21
|
+
|
|
22
|
+
const columns: Column<Item>[] = [
|
|
23
|
+
{ id: 'product', header: 'Product', rowHeader: true },
|
|
24
|
+
{ id: 'quantity', header: 'Quantity', numeric: true },
|
|
25
|
+
{ id: 'price', header: 'Price', numeric: true, value: (item) => money(item.priceCents) },
|
|
26
|
+
{
|
|
27
|
+
id: 'line',
|
|
28
|
+
header: 'Line total',
|
|
29
|
+
numeric: true,
|
|
30
|
+
value: (item) => money(item.priceCents * item.quantity),
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export function OrderDetail({ id }: { id: number }) {
|
|
35
|
+
const order = getOrder(() => ({ id }))
|
|
36
|
+
const items = derived(() => order.value?.items ?? [])
|
|
37
|
+
const save = (status: Status) =>
|
|
38
|
+
setOrderStatus({ id, status })
|
|
39
|
+
.then(() => toast.show({ tone: 'positive', title: `Order #${id} is now ${status}` }))
|
|
40
|
+
.catch(() =>
|
|
41
|
+
toast.show({
|
|
42
|
+
tone: 'critical',
|
|
43
|
+
title: "That status wasn't saved",
|
|
44
|
+
description: 'The order is as it was. Try again in a moment.',
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Stack gap="group">
|
|
50
|
+
<div class="section-head">
|
|
51
|
+
<Heading level={1} role="title">
|
|
52
|
+
Order #{id}
|
|
53
|
+
</Heading>
|
|
54
|
+
{order.value && <StatusBadge status={order.value.status} />}
|
|
55
|
+
</div>
|
|
56
|
+
<Grid min="16rem" gap="related">
|
|
57
|
+
<Card eyebrow="Customer">
|
|
58
|
+
<Stack gap="tight">
|
|
59
|
+
<strong>{order.value?.customer}</strong>
|
|
60
|
+
<Text subtle>{order.value?.email}</Text>
|
|
61
|
+
<Text subtle>{order.value?.city}</Text>
|
|
62
|
+
</Stack>
|
|
63
|
+
</Card>
|
|
64
|
+
<Card eyebrow="Order">
|
|
65
|
+
<Stack gap="tight">
|
|
66
|
+
<strong>{money(order.value?.totalCents ?? 0)}</strong>
|
|
67
|
+
<Text subtle>Placed {order.value ? date(order.value.createdAt) : ''}</Text>
|
|
68
|
+
</Stack>
|
|
69
|
+
</Card>
|
|
70
|
+
<Card eyebrow="Fulfilment">
|
|
71
|
+
<Select
|
|
72
|
+
label="Status"
|
|
73
|
+
options={options}
|
|
74
|
+
value={order.value?.status}
|
|
75
|
+
disabled={setOrderStatus.pending.value}
|
|
76
|
+
onChange={(e) => save((e.currentTarget as HTMLSelectElement).value as Status)}
|
|
77
|
+
/>
|
|
78
|
+
</Card>
|
|
79
|
+
</Grid>
|
|
80
|
+
<Table
|
|
81
|
+
caption="Items"
|
|
82
|
+
rows={items}
|
|
83
|
+
columns={columns}
|
|
84
|
+
picker={false}
|
|
85
|
+
empty={{ title: 'No items', text: 'This order has no line items.' }}
|
|
86
|
+
/>
|
|
87
|
+
</Stack>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const OrderLoading = () => (
|
|
92
|
+
<Skeleton lines={8} height="2rem" aria-label="Loading the order" />
|
|
93
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { navigate } from '@weft-js/start'
|
|
2
|
+
import { Input, Select } from '@weft-js/warp'
|
|
3
|
+
import { defaultView, statuses, viewHref, type OrderView } from '#/lib/orders'
|
|
4
|
+
|
|
5
|
+
const options = [
|
|
6
|
+
{ value: '', label: 'all' },
|
|
7
|
+
...statuses.map((status) => ({ value: status, label: status })),
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
/** A real GET form, so filters work before the page has hydrated. */
|
|
11
|
+
export function OrdersFilters({ view }: { view: OrderView }) {
|
|
12
|
+
const show = (change: Partial<OrderView>) => navigate(viewHref({ ...view, page: 1, ...change }))
|
|
13
|
+
const search = (e: SubmitEvent) => {
|
|
14
|
+
e.preventDefault()
|
|
15
|
+
const q = new FormData(e.currentTarget as HTMLFormElement).get('q')
|
|
16
|
+
show({ q: typeof q === 'string' ? q.trim() : '' })
|
|
17
|
+
}
|
|
18
|
+
return (
|
|
19
|
+
<form class="filters" method="get" action="/admin/orders" onSubmit={search}>
|
|
20
|
+
{view.sort !== defaultView.sort || view.dir !== defaultView.dir ? (
|
|
21
|
+
<>
|
|
22
|
+
<input type="hidden" name="sort" value={view.sort} />
|
|
23
|
+
<input type="hidden" name="dir" value={view.dir} />
|
|
24
|
+
</>
|
|
25
|
+
) : null}
|
|
26
|
+
<Input
|
|
27
|
+
label="Search"
|
|
28
|
+
name="q"
|
|
29
|
+
placeholder="Customer name"
|
|
30
|
+
autocomplete="off"
|
|
31
|
+
value={view.q}
|
|
32
|
+
/>
|
|
33
|
+
<Select
|
|
34
|
+
label="Status"
|
|
35
|
+
name="status"
|
|
36
|
+
options={options}
|
|
37
|
+
value={view.status}
|
|
38
|
+
onChange={(e) =>
|
|
39
|
+
show({ status: (e.currentTarget as HTMLSelectElement).value as OrderView['status'] })
|
|
40
|
+
}
|
|
41
|
+
/>
|
|
42
|
+
</form>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { navigate } from '@weft-js/start'
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Link,
|
|
5
|
+
Skeleton,
|
|
6
|
+
Table,
|
|
7
|
+
Text,
|
|
8
|
+
toast,
|
|
9
|
+
type Column,
|
|
10
|
+
type TableSort,
|
|
11
|
+
} from '@weft-js/warp'
|
|
12
|
+
import { derived, state, watch, type State } from '@weft-js/core'
|
|
13
|
+
import { StatusBadge } from '#/components/StatusBadge'
|
|
14
|
+
import { count, date, money, PAGE_SIZE, viewHref, type OrderView, type SortKey } from '#/lib/orders'
|
|
15
|
+
import { listOrders, markShipped, type OrderRow } from '#/server/orders'
|
|
16
|
+
|
|
17
|
+
const columns: Column<OrderRow>[] = [
|
|
18
|
+
{
|
|
19
|
+
id: 'id',
|
|
20
|
+
header: 'Order',
|
|
21
|
+
sort: true,
|
|
22
|
+
rowHeader: true,
|
|
23
|
+
value: (row) => `Order ${row.id}`,
|
|
24
|
+
cell: (row) => <Link href={`/admin/orders/${row.id}`}>#{row.id}</Link>,
|
|
25
|
+
},
|
|
26
|
+
{ id: 'customer', header: 'Customer', sort: true },
|
|
27
|
+
{
|
|
28
|
+
id: 'status',
|
|
29
|
+
header: 'Status',
|
|
30
|
+
sort: true,
|
|
31
|
+
card: 'badge',
|
|
32
|
+
cell: (row) => <StatusBadge status={row.status} />,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'total',
|
|
36
|
+
header: 'Total',
|
|
37
|
+
sort: true,
|
|
38
|
+
numeric: true,
|
|
39
|
+
value: (row) => money(row.totalCents),
|
|
40
|
+
},
|
|
41
|
+
{ id: 'created', header: 'Date', sort: true, value: (row) => date(row.createdAt) },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
const tableSort = (view: OrderView): TableSort => ({
|
|
45
|
+
id: view.sort,
|
|
46
|
+
dir: view.dir === 'asc' ? 'ascending' : 'descending',
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
async function shipOrders(
|
|
50
|
+
keys: string[],
|
|
51
|
+
view: () => OrderView,
|
|
52
|
+
selected: State<Record<string, boolean>>,
|
|
53
|
+
) {
|
|
54
|
+
try {
|
|
55
|
+
await markShipped({ ids: keys.map(Number), view: { ...view() } })
|
|
56
|
+
selected.value = {}
|
|
57
|
+
toast.show({
|
|
58
|
+
tone: 'positive',
|
|
59
|
+
title: keys.length === 1 ? '1 order marked shipped' : `${keys.length} orders marked shipped`,
|
|
60
|
+
})
|
|
61
|
+
} catch {
|
|
62
|
+
toast.show({
|
|
63
|
+
tone: 'critical',
|
|
64
|
+
title: "Those orders weren't updated",
|
|
65
|
+
description: 'Your selection is still here. Try again in a moment.',
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function OrdersTable({ view }: { view: OrderView }) {
|
|
71
|
+
const orders = listOrders(() => ({ ...view }))
|
|
72
|
+
const rows = derived(() => orders.value?.rows ?? [])
|
|
73
|
+
const total = derived(() => orders.value?.total ?? 0)
|
|
74
|
+
const pages = derived(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
|
|
75
|
+
const selected = state<Record<string, boolean>>({})
|
|
76
|
+
const sort = state.linked(() => tableSort(view))
|
|
77
|
+
|
|
78
|
+
// A ticked row off screen shouldn't be shipped by accident.
|
|
79
|
+
watch(
|
|
80
|
+
() => viewHref(view),
|
|
81
|
+
() => {
|
|
82
|
+
selected.value = {}
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const sortBy = (next: TableSort) =>
|
|
87
|
+
navigate(
|
|
88
|
+
viewHref({
|
|
89
|
+
...view,
|
|
90
|
+
page: 1,
|
|
91
|
+
sort: next.id as SortKey,
|
|
92
|
+
dir: next.dir === 'ascending' ? 'asc' : 'desc',
|
|
93
|
+
}),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const currentView = () => view
|
|
97
|
+
const ship = (keys: string[]) => shipOrders(keys, currentView, selected)
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
<Table
|
|
102
|
+
caption="Orders"
|
|
103
|
+
captionHidden
|
|
104
|
+
rows={rows}
|
|
105
|
+
columns={columns}
|
|
106
|
+
server
|
|
107
|
+
sort={sort}
|
|
108
|
+
onSortChange={sortBy}
|
|
109
|
+
selection={selected}
|
|
110
|
+
bulk={(keys) => (
|
|
111
|
+
<Button loading={markShipped.pending.value} onClick={() => ship(keys)}>
|
|
112
|
+
Mark shipped
|
|
113
|
+
</Button>
|
|
114
|
+
)}
|
|
115
|
+
empty={{
|
|
116
|
+
title: 'No orders match',
|
|
117
|
+
text:
|
|
118
|
+
view.q || view.status
|
|
119
|
+
? 'Try another name, or a different status.'
|
|
120
|
+
: 'New orders land here the moment a customer checks out.',
|
|
121
|
+
}}
|
|
122
|
+
/>
|
|
123
|
+
<nav class="pager" aria-label="Pages">
|
|
124
|
+
{view.page > 1 ? (
|
|
125
|
+
<Link href={viewHref({ ...view, page: view.page - 1 })}>Previous</Link>
|
|
126
|
+
) : (
|
|
127
|
+
<span aria-disabled="true">Previous</span>
|
|
128
|
+
)}
|
|
129
|
+
<Text subtle>
|
|
130
|
+
Page {view.page} of {count(pages.value)}, {count(total.value)} orders
|
|
131
|
+
</Text>
|
|
132
|
+
{view.page < pages.value ? (
|
|
133
|
+
<Link href={viewHref({ ...view, page: view.page + 1 })}>Next</Link>
|
|
134
|
+
) : (
|
|
135
|
+
<span aria-disabled="true">Next</span>
|
|
136
|
+
)}
|
|
137
|
+
</nav>
|
|
138
|
+
</>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const OrdersLoading = () => (
|
|
143
|
+
<Skeleton lines={12} height="1.75rem" aria-label="Loading orders" />
|
|
144
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BarChart,
|
|
3
|
+
Card,
|
|
4
|
+
Grid,
|
|
5
|
+
Heading,
|
|
6
|
+
Link,
|
|
7
|
+
Skeleton,
|
|
8
|
+
Stack,
|
|
9
|
+
Table,
|
|
10
|
+
Text,
|
|
11
|
+
type Column,
|
|
12
|
+
} from '@weft-js/warp'
|
|
13
|
+
import { derived } from '@weft-js/core'
|
|
14
|
+
import { StatusBadge } from '#/components/StatusBadge'
|
|
15
|
+
import { count, date, money, roundMoney } from '#/lib/orders'
|
|
16
|
+
import { latestOrders, revenueByMonth, storeStats, type OrderRow } from '#/server/orders'
|
|
17
|
+
|
|
18
|
+
const dollars = (n: number) => roundMoney(n * 100)
|
|
19
|
+
|
|
20
|
+
function Stat({ label, value }: { label: string; value: string }) {
|
|
21
|
+
return (
|
|
22
|
+
<Card eyebrow={label} class="stat">
|
|
23
|
+
<Text role="title">{value}</Text>
|
|
24
|
+
</Card>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Stats() {
|
|
29
|
+
const stats = storeStats()
|
|
30
|
+
return (
|
|
31
|
+
<Grid min="12rem" gap="related">
|
|
32
|
+
<Stat label="Revenue" value={roundMoney(stats.value?.revenue ?? 0)} />
|
|
33
|
+
<Stat label="Orders" value={count(stats.value?.orders ?? 0)} />
|
|
34
|
+
<Stat label="Customers" value={count(stats.value?.customers ?? 0)} />
|
|
35
|
+
<Stat label="Refunded" value={roundMoney(stats.value?.refunded ?? 0)} />
|
|
36
|
+
</Grid>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const StatsLoading = () => (
|
|
41
|
+
<Grid min="12rem" gap="related" aria-busy="true" aria-label="Loading the totals">
|
|
42
|
+
<Skeleton height="5.25rem" />
|
|
43
|
+
<Skeleton height="5.25rem" />
|
|
44
|
+
<Skeleton height="5.25rem" />
|
|
45
|
+
<Skeleton height="5.25rem" />
|
|
46
|
+
</Grid>
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
/* @weft visible */
|
|
50
|
+
export function RevenueChart() {
|
|
51
|
+
const months = revenueByMonth()
|
|
52
|
+
return (
|
|
53
|
+
<BarChart
|
|
54
|
+
title="Revenue by month"
|
|
55
|
+
description="Everything sold in each of the last twelve months, less refunds."
|
|
56
|
+
data={months.value ?? []}
|
|
57
|
+
x="month"
|
|
58
|
+
series={[{ key: 'revenue', name: 'Revenue', color: 'var(--warp-color-accent-solid)' }]}
|
|
59
|
+
format={dollars}
|
|
60
|
+
axisFormat={dollars}
|
|
61
|
+
empty={{
|
|
62
|
+
title: 'No sales yet',
|
|
63
|
+
detail: 'Your first month will show up here as soon as an order comes in.',
|
|
64
|
+
}}
|
|
65
|
+
/>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const RevenueChartLoading = () => <Skeleton height="24rem" aria-label="Loading the chart" />
|
|
70
|
+
|
|
71
|
+
const columns: Column<OrderRow>[] = [
|
|
72
|
+
{
|
|
73
|
+
id: 'id',
|
|
74
|
+
header: 'Order',
|
|
75
|
+
rowHeader: true,
|
|
76
|
+
value: (row) => `Order ${row.id}`,
|
|
77
|
+
cell: (row) => <Link href={`/admin/orders/${row.id}`}>#{row.id}</Link>,
|
|
78
|
+
},
|
|
79
|
+
{ id: 'customer', header: 'Customer' },
|
|
80
|
+
{
|
|
81
|
+
id: 'status',
|
|
82
|
+
header: 'Status',
|
|
83
|
+
card: 'badge',
|
|
84
|
+
cell: (row) => <StatusBadge status={row.status} />,
|
|
85
|
+
},
|
|
86
|
+
{ id: 'total', header: 'Total', numeric: true, value: (row) => money(row.totalCents) },
|
|
87
|
+
{ id: 'date', header: 'Date', value: (row) => date(row.createdAt) },
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
export function LatestOrders() {
|
|
91
|
+
const latest = latestOrders()
|
|
92
|
+
const rows = derived(() => latest.value ?? [])
|
|
93
|
+
return (
|
|
94
|
+
<Stack gap="related">
|
|
95
|
+
<div class="section-head">
|
|
96
|
+
<Heading level={2}>Latest orders</Heading>
|
|
97
|
+
<Link href="/admin/orders">See all orders</Link>
|
|
98
|
+
</div>
|
|
99
|
+
<Table
|
|
100
|
+
caption="Latest orders"
|
|
101
|
+
captionHidden
|
|
102
|
+
rows={rows}
|
|
103
|
+
columns={columns}
|
|
104
|
+
picker={false}
|
|
105
|
+
empty={{ title: 'No orders yet', text: 'They land here the moment a customer checks out.' }}
|
|
106
|
+
/>
|
|
107
|
+
</Stack>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const LatestOrdersLoading = () => (
|
|
112
|
+
<Skeleton lines={10} height="1.75rem" aria-label="Loading the latest orders" />
|
|
113
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Badge, type Tone } from '@weft-js/warp'
|
|
2
|
+
|
|
3
|
+
const tones: Record<string, Tone> = {
|
|
4
|
+
pending: 'caution',
|
|
5
|
+
paid: 'info',
|
|
6
|
+
shipped: 'positive',
|
|
7
|
+
refunded: 'critical',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function StatusBadge({ status }: { status: string }) {
|
|
11
|
+
return <Badge tone={tones[status]}>{status}</Badge>
|
|
12
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { desc } from 'drizzle-orm'
|
|
2
|
+
import { boolean, index, integer, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'
|
|
3
|
+
|
|
4
|
+
export * from './auth'
|
|
5
|
+
|
|
6
|
+
export const customers = pgTable('customers', {
|
|
7
|
+
id: serial('id').primaryKey(),
|
|
8
|
+
name: text('name').notNull(),
|
|
9
|
+
email: text('email').notNull().unique(),
|
|
10
|
+
city: text('city').notNull(),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export const orders = pgTable(
|
|
14
|
+
'orders',
|
|
15
|
+
{
|
|
16
|
+
id: serial('id').primaryKey(),
|
|
17
|
+
customerId: integer('customer_id')
|
|
18
|
+
.notNull()
|
|
19
|
+
.references(() => customers.id),
|
|
20
|
+
status: text('status').notNull(),
|
|
21
|
+
totalCents: integer('total_cents').notNull(),
|
|
22
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
|
23
|
+
},
|
|
24
|
+
(table) => [
|
|
25
|
+
index('orders_created').on(desc(table.createdAt), desc(table.id)),
|
|
26
|
+
index('orders_status').on(table.status, desc(table.createdAt)),
|
|
27
|
+
index('orders_customer').on(table.customerId),
|
|
28
|
+
],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
export const categories = pgTable('categories', {
|
|
32
|
+
id: serial('id').primaryKey(),
|
|
33
|
+
slug: text('slug').notNull().unique(),
|
|
34
|
+
name: text('name').notNull(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export const products = pgTable(
|
|
38
|
+
'products',
|
|
39
|
+
{
|
|
40
|
+
id: serial('id').primaryKey(),
|
|
41
|
+
slug: text('slug').notNull().unique(),
|
|
42
|
+
name: text('name').notNull(),
|
|
43
|
+
description: text('description').notNull(),
|
|
44
|
+
categoryId: integer('category_id')
|
|
45
|
+
.notNull()
|
|
46
|
+
.references(() => categories.id),
|
|
47
|
+
priceCents: integer('price_cents').notNull(),
|
|
48
|
+
stock: integer('stock').notNull(),
|
|
49
|
+
featured: boolean('featured').notNull().default(false),
|
|
50
|
+
},
|
|
51
|
+
(table) => [index('products_category').on(table.categoryId, table.priceCents, table.id)],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
export const orderItems = pgTable(
|
|
55
|
+
'order_items',
|
|
56
|
+
{
|
|
57
|
+
id: serial('id').primaryKey(),
|
|
58
|
+
orderId: integer('order_id')
|
|
59
|
+
.notNull()
|
|
60
|
+
.references(() => orders.id, { onDelete: 'cascade' }),
|
|
61
|
+
productId: integer('product_id')
|
|
62
|
+
.notNull()
|
|
63
|
+
.references(() => products.id),
|
|
64
|
+
quantity: integer('quantity').notNull(),
|
|
65
|
+
priceCents: integer('price_cents').notNull(),
|
|
66
|
+
},
|
|
67
|
+
(table) => [index('order_items_order').on(table.orderId)],
|
|
68
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const PAGE_SIZE = 50
|
|
2
|
+
|
|
3
|
+
export const statuses = ['pending', 'paid', 'shipped', 'refunded'] as const
|
|
4
|
+
export type Status = (typeof statuses)[number]
|
|
5
|
+
|
|
6
|
+
export const sortKeys = ['id', 'customer', 'status', 'total', 'created'] as const
|
|
7
|
+
export type SortKey = (typeof sortKeys)[number]
|
|
8
|
+
export type Dir = 'asc' | 'desc'
|
|
9
|
+
|
|
10
|
+
/** What `/admin/orders` is showing. It all lives in the URL, so a view can be reloaded, shared and gone back to. */
|
|
11
|
+
export interface OrderView {
|
|
12
|
+
page: number
|
|
13
|
+
sort: SortKey
|
|
14
|
+
dir: Dir
|
|
15
|
+
status: Status | ''
|
|
16
|
+
q: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const defaultView: OrderView = { page: 1, sort: 'created', dir: 'desc', status: '', q: '' }
|
|
20
|
+
|
|
21
|
+
export function viewOf(url: string): OrderView {
|
|
22
|
+
const asked = new URL(url).searchParams
|
|
23
|
+
const page = Number(asked.get('page'))
|
|
24
|
+
const sort = sortKeys.find((key) => key === asked.get('sort'))
|
|
25
|
+
const status = statuses.find((key) => key === asked.get('status'))
|
|
26
|
+
return {
|
|
27
|
+
page: Number.isSafeInteger(page) && page > 0 && page < 1e6 ? page : 1,
|
|
28
|
+
sort: sort ?? defaultView.sort,
|
|
29
|
+
dir: asked.get('dir') === 'asc' ? 'asc' : 'desc',
|
|
30
|
+
status: status ?? '',
|
|
31
|
+
q: (asked.get('q') ?? '').trim().slice(0, 100),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function viewHref(view: OrderView) {
|
|
36
|
+
const query = new URLSearchParams()
|
|
37
|
+
if (view.status) query.set('status', view.status)
|
|
38
|
+
if (view.q) query.set('q', view.q)
|
|
39
|
+
if (view.sort !== defaultView.sort || view.dir !== defaultView.dir) {
|
|
40
|
+
query.set('sort', view.sort)
|
|
41
|
+
query.set('dir', view.dir)
|
|
42
|
+
}
|
|
43
|
+
if (view.page > 1) query.set('page', String(view.page))
|
|
44
|
+
const search = query.toString()
|
|
45
|
+
return search ? `/admin/orders?${search}` : '/admin/orders'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const dollars = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
|
|
49
|
+
const wholeDollars = new Intl.NumberFormat('en-US', {
|
|
50
|
+
style: 'currency',
|
|
51
|
+
currency: 'USD',
|
|
52
|
+
maximumFractionDigits: 0,
|
|
53
|
+
})
|
|
54
|
+
const day = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' })
|
|
55
|
+
|
|
56
|
+
export const money = (cents: number) => dollars.format(cents / 100)
|
|
57
|
+
export const roundMoney = (cents: number) => wholeDollars.format(cents / 100)
|
|
58
|
+
const number = new Intl.NumberFormat('en-US')
|
|
59
|
+
export const count = (n: number) => number.format(n)
|
|
60
|
+
export const date = (iso: string) => day.format(new Date(iso))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Link, type Meta } from '@weft-js/start'
|
|
2
|
+
import { Center, Heading, Stack, Text } from '@weft-js/warp'
|
|
3
|
+
|
|
4
|
+
export const meta: Meta = { title: 'Page not found' }
|
|
5
|
+
|
|
6
|
+
export default function NotFound() {
|
|
7
|
+
return (
|
|
8
|
+
<main class="system-page">
|
|
9
|
+
<Center max="narrow" gutter>
|
|
10
|
+
<Stack gap="group" align="center">
|
|
11
|
+
<Heading level={1} role="title">
|
|
12
|
+
Nothing on this shelf
|
|
13
|
+
</Heading>
|
|
14
|
+
<Text subtle>The order may be gone, or the address mistyped.</Text>
|
|
15
|
+
<Link href="/admin">Back to the overview</Link>
|
|
16
|
+
</Stack>
|
|
17
|
+
</Center>
|
|
18
|
+
</main>
|
|
19
|
+
)
|
|
20
|
+
}
|