@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.
Files changed (127) hide show
  1. package/README.md +60 -10
  2. package/bin/create-weft.mjs +62 -14
  3. package/package.json +26 -16
  4. package/scripts/tarballs.mjs +56 -6
  5. package/src/data.mjs +90 -18
  6. package/src/index.mjs +210 -51
  7. package/src/stack/pages.mjs +151 -0
  8. package/src/stack/schema.mjs +195 -0
  9. package/src/stack.mjs +102 -313
  10. package/src/templates.mjs +174 -0
  11. package/src/wizard.mjs +65 -16
  12. package/templates/admin/files/drizzle/.schema-hash +1 -0
  13. package/templates/admin/files/drizzle/0000_admin.sql +136 -0
  14. package/templates/admin/files/drizzle/meta/0000_snapshot.json +771 -0
  15. package/templates/admin/files/drizzle/meta/_journal.json +13 -0
  16. package/templates/admin/files/public/favicon.svg +1 -0
  17. package/templates/admin/files/src/auth.server.ts +24 -0
  18. package/templates/admin/files/src/components/AuthPage.tsx +33 -0
  19. package/templates/admin/files/src/components/Failed.tsx +19 -0
  20. package/templates/admin/files/src/components/Logo.tsx +16 -0
  21. package/templates/admin/files/src/components/OrderDetail.tsx +93 -0
  22. package/templates/admin/files/src/components/OrdersFilters.tsx +44 -0
  23. package/templates/admin/files/src/components/OrdersTable.tsx +144 -0
  24. package/templates/admin/files/src/components/Overview.tsx +113 -0
  25. package/templates/admin/files/src/components/StatusBadge.tsx +12 -0
  26. package/templates/admin/files/src/db/schema.ts +68 -0
  27. package/templates/admin/files/src/lib/orders.ts +60 -0
  28. package/templates/admin/files/src/routes/404.tsx +20 -0
  29. package/templates/admin/files/src/routes/_error.tsx +20 -0
  30. package/templates/admin/files/src/routes/_layout.tsx +6 -0
  31. package/templates/admin/files/src/routes/admin/_layout.tsx +89 -0
  32. package/templates/admin/files/src/routes/admin/index.tsx +45 -0
  33. package/templates/admin/files/src/routes/admin/nested/_layout.tsx +35 -0
  34. package/templates/admin/files/src/routes/admin/nested/index.tsx +77 -0
  35. package/templates/admin/files/src/routes/admin/orders/[id].tsx +24 -0
  36. package/templates/admin/files/src/routes/admin/orders/index.tsx +28 -0
  37. package/templates/admin/files/src/routes/admin/per-row.tsx +81 -0
  38. package/templates/admin/files/src/routes/admin/slow.tsx +74 -0
  39. package/templates/admin/files/src/routes/index.tsx +13 -0
  40. package/templates/admin/files/src/routes/login.tsx +53 -0
  41. package/templates/admin/files/src/routes/signup.tsx +60 -0
  42. package/templates/admin/files/src/server/orders.ts +193 -0
  43. package/templates/admin/files/src/server/reports.ts +92 -0
  44. package/templates/admin/files/src/server/session.ts +42 -0
  45. package/templates/admin/files/src/theme.css +43 -0
  46. package/templates/admin/files/weft.config.ts +31 -0
  47. package/templates/admin/template.json +22 -0
  48. package/templates/catalog/files/drizzle/.schema-hash +1 -0
  49. package/templates/catalog/files/drizzle/0000_catalog.sql +47 -0
  50. package/templates/catalog/files/drizzle/meta/0000_snapshot.json +263 -0
  51. package/templates/catalog/files/drizzle/meta/_journal.json +13 -0
  52. package/templates/catalog/files/public/favicon.svg +1 -0
  53. package/templates/catalog/files/seed.sql +13 -0
  54. package/templates/catalog/files/src/components/AddToCart.tsx +22 -0
  55. package/templates/catalog/files/src/components/Aisles.tsx +31 -0
  56. package/templates/catalog/files/src/components/CartLines.tsx +96 -0
  57. package/templates/catalog/files/src/components/CartLink.tsx +29 -0
  58. package/templates/catalog/files/src/components/Failed.tsx +19 -0
  59. package/templates/catalog/files/src/components/Featured.tsx +19 -0
  60. package/templates/catalog/files/src/components/Filters.tsx +40 -0
  61. package/templates/catalog/files/src/components/Logo.tsx +16 -0
  62. package/templates/catalog/files/src/components/ProductArt.tsx +42 -0
  63. package/templates/catalog/files/src/components/ProductCard.tsx +42 -0
  64. package/templates/catalog/files/src/components/ProductList.tsx +65 -0
  65. package/templates/catalog/files/src/components/Related.tsx +19 -0
  66. package/templates/catalog/files/src/components/SiteFooter.tsx +9 -0
  67. package/templates/catalog/files/src/components/SiteHeader.tsx +16 -0
  68. package/templates/catalog/files/src/db/schema.ts +53 -0
  69. package/templates/catalog/files/src/lib/catalog.ts +35 -0
  70. package/templates/catalog/files/src/routes/404.tsx +18 -0
  71. package/templates/catalog/files/src/routes/_error.tsx +18 -0
  72. package/templates/catalog/files/src/routes/_layout.tsx +14 -0
  73. package/templates/catalog/files/src/routes/c/[slug].tsx +53 -0
  74. package/templates/catalog/files/src/routes/cart.tsx +29 -0
  75. package/templates/catalog/files/src/routes/index.tsx +84 -0
  76. package/templates/catalog/files/src/routes/p/[slug].tsx +67 -0
  77. package/templates/catalog/files/src/server/cart.ts +125 -0
  78. package/templates/catalog/files/src/server/catalog.ts +107 -0
  79. package/templates/catalog/files/src/theme.css +159 -0
  80. package/templates/catalog/files/weft.config.ts +30 -0
  81. package/templates/catalog/template.json +20 -0
  82. package/templates/landing/files/.weft/plugins.lock.json +42 -0
  83. package/templates/landing/files/assets/fonts/LICENSE-figtree.txt +93 -0
  84. package/templates/landing/files/assets/fonts/figtree.woff2 +0 -0
  85. package/templates/landing/files/assets/hero.webp +0 -0
  86. package/templates/landing/files/drizzle/.schema-hash +1 -0
  87. package/templates/landing/files/drizzle/0000_landing.sql +6 -0
  88. package/templates/landing/files/drizzle/meta/0000_snapshot.json +60 -0
  89. package/templates/landing/files/drizzle/meta/_journal.json +13 -0
  90. package/templates/landing/files/public/favicon.svg +1 -0
  91. package/templates/landing/files/src/components/SiteFooter.tsx +24 -0
  92. package/templates/landing/files/src/components/SiteHeader.tsx +23 -0
  93. package/templates/landing/files/src/components/WaitlistForm.tsx +36 -0
  94. package/templates/landing/files/src/db/schema.ts +7 -0
  95. package/templates/landing/files/src/routes/404.tsx +24 -0
  96. package/templates/landing/files/src/routes/_error.tsx +24 -0
  97. package/templates/landing/files/src/routes/_layout.tsx +14 -0
  98. package/templates/landing/files/src/routes/index.tsx +130 -0
  99. package/templates/landing/files/src/routes/joined.tsx +30 -0
  100. package/templates/landing/files/src/routes/pricing.tsx +131 -0
  101. package/templates/landing/files/src/routes/privacy.tsx +56 -0
  102. package/templates/landing/files/src/server/waitlist.ts +13 -0
  103. package/templates/landing/files/src/theme.css +108 -0
  104. package/templates/landing/files/weft.config.ts +44 -0
  105. package/templates/landing/template.json +17 -0
  106. package/templates/todo/files/drizzle/.schema-hash +1 -0
  107. package/templates/todo/files/drizzle/0000_todo.sql +82 -0
  108. package/templates/todo/files/drizzle/meta/0000_snapshot.json +462 -0
  109. package/templates/todo/files/drizzle/meta/_journal.json +13 -0
  110. package/templates/todo/files/public/favicon.svg +1 -0
  111. package/templates/todo/files/src/components/AuthPage.tsx +33 -0
  112. package/templates/todo/files/src/components/Logo.tsx +14 -0
  113. package/templates/todo/files/src/components/NewTodo.tsx +27 -0
  114. package/templates/todo/files/src/components/TodoList.tsx +101 -0
  115. package/templates/todo/files/src/db/schema.ts +18 -0
  116. package/templates/todo/files/src/lib/views.ts +13 -0
  117. package/templates/todo/files/src/routes/404.tsx +20 -0
  118. package/templates/todo/files/src/routes/_error.tsx +20 -0
  119. package/templates/todo/files/src/routes/_layout.tsx +6 -0
  120. package/templates/todo/files/src/routes/index.tsx +13 -0
  121. package/templates/todo/files/src/routes/login.tsx +53 -0
  122. package/templates/todo/files/src/routes/signup.tsx +57 -0
  123. package/templates/todo/files/src/routes/todos.tsx +87 -0
  124. package/templates/todo/files/src/server/todos.ts +98 -0
  125. package/templates/todo/files/src/theme.css +71 -0
  126. package/templates/todo/files/weft.config.ts +30 -0
  127. package/templates/todo/template.json +18 -0
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1793581546991,
9
+ "tag": "0000_admin",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -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
+ }