@weft-js/create 0.1.1 → 0.1.3
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 +393 -19
- 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,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: 'Something went wrong' }
|
|
5
|
+
|
|
6
|
+
export default function ServerError() {
|
|
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
|
+
Something went wrong on our side
|
|
13
|
+
</Heading>
|
|
14
|
+
<Text subtle>Your orders are safe. Give it a minute and try again.</Text>
|
|
15
|
+
<Link href="/admin">Back to the overview</Link>
|
|
16
|
+
</Stack>
|
|
17
|
+
</Center>
|
|
18
|
+
</main>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BarChartIcon,
|
|
3
|
+
ClipboardListIcon,
|
|
4
|
+
HomeIcon,
|
|
5
|
+
ListIcon,
|
|
6
|
+
LogOutIcon,
|
|
7
|
+
StoreIcon,
|
|
8
|
+
} from '@weft-js/warp-icons'
|
|
9
|
+
import { redirect, type LoadArgs, type RouteProps } from '@weft-js/start'
|
|
10
|
+
import { Shell, Toaster, toast } from '@weft-js/warp'
|
|
11
|
+
import { Logo } from '#/components/Logo'
|
|
12
|
+
import { signOut } from '#/server/session'
|
|
13
|
+
|
|
14
|
+
const pages = {
|
|
15
|
+
'/admin': { id: 'overview', label: 'Overview' },
|
|
16
|
+
'/admin/orders': { id: 'orders', label: 'Orders' },
|
|
17
|
+
'/admin/nested': { id: 'regions', label: 'Regions' },
|
|
18
|
+
'/admin/per-row': { id: 'buyers', label: 'Order feed' },
|
|
19
|
+
'/admin/slow': { id: 'report', label: 'Year report' },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The guard for every page under /admin; each page's own queries are `auth: 'user'` as well.
|
|
23
|
+
export const load = ({ ctx, request }: LoadArgs) => {
|
|
24
|
+
if (!ctx.user) redirect('/login')
|
|
25
|
+
const path = new URL(request.url).pathname.replace(/\/$/, '')
|
|
26
|
+
const detail = path.match(/^\/admin\/orders\/(.+)$/)
|
|
27
|
+
const page = pages[(detail ? '/admin/orders' : path) as keyof typeof pages] ?? pages['/admin']
|
|
28
|
+
const crumbs = [
|
|
29
|
+
{ label: 'Backroom', href: '/admin' },
|
|
30
|
+
...(detail
|
|
31
|
+
? [{ label: 'Orders', href: '/admin/orders' }, { label: `#${detail[1]}` }]
|
|
32
|
+
: [{ label: page.label }]),
|
|
33
|
+
]
|
|
34
|
+
return { name: ctx.user.name, email: ctx.user.email, current: page.id, crumbs }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const nav = [
|
|
38
|
+
{
|
|
39
|
+
items: [
|
|
40
|
+
{ id: 'overview', label: 'Overview', href: '/admin', icon: HomeIcon },
|
|
41
|
+
{ id: 'orders', label: 'Orders', href: '/admin/orders', icon: ClipboardListIcon },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: 'Reports',
|
|
46
|
+
items: [
|
|
47
|
+
{ id: 'regions', label: 'Regions', href: '/admin/nested', icon: StoreIcon },
|
|
48
|
+
{ id: 'buyers', label: 'Order feed', href: '/admin/per-row', icon: ListIcon },
|
|
49
|
+
{ id: 'report', label: 'Year report', href: '/admin/slow', icon: BarChartIcon },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const leave = () =>
|
|
55
|
+
signOut().catch(() =>
|
|
56
|
+
toast.show({
|
|
57
|
+
tone: 'critical',
|
|
58
|
+
title: "You're still signed in",
|
|
59
|
+
description: "That didn't go through. Try again in a moment.",
|
|
60
|
+
}),
|
|
61
|
+
)
|
|
62
|
+
const initials = (name: string) =>
|
|
63
|
+
name
|
|
64
|
+
.split(/\s+/)
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.slice(0, 2)
|
|
67
|
+
.map((word) => word[0])
|
|
68
|
+
.join('')
|
|
69
|
+
.toUpperCase()
|
|
70
|
+
|
|
71
|
+
export default function Admin(props: RouteProps<typeof load>) {
|
|
72
|
+
return (
|
|
73
|
+
<Shell
|
|
74
|
+
brand={{ name: 'Backroom', href: '/admin', mark: <Logo /> }}
|
|
75
|
+
nav={nav}
|
|
76
|
+
current={props.data.current}
|
|
77
|
+
crumbs={props.data.crumbs}
|
|
78
|
+
account={{
|
|
79
|
+
name: props.data.name,
|
|
80
|
+
email: props.data.email,
|
|
81
|
+
initials: initials(props.data.name),
|
|
82
|
+
items: [{ label: 'Sign out', icon: LogOutIcon, onSelect: leave }],
|
|
83
|
+
}}
|
|
84
|
+
overlays={<Toaster />}
|
|
85
|
+
>
|
|
86
|
+
<div class="page">{props.children}</div>
|
|
87
|
+
</Shell>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Meta } from '@weft-js/start'
|
|
2
|
+
import { Heading, Stack, Text } from '@weft-js/warp'
|
|
3
|
+
import { Boundary } from '@weft-js/core'
|
|
4
|
+
import { Failed } from '#/components/Failed'
|
|
5
|
+
import {
|
|
6
|
+
LatestOrders,
|
|
7
|
+
LatestOrdersLoading,
|
|
8
|
+
RevenueChart,
|
|
9
|
+
RevenueChartLoading,
|
|
10
|
+
Stats,
|
|
11
|
+
StatsLoading,
|
|
12
|
+
} from '#/components/Overview'
|
|
13
|
+
|
|
14
|
+
export const meta: Meta = { title: 'Overview' }
|
|
15
|
+
|
|
16
|
+
export default function Overview() {
|
|
17
|
+
return (
|
|
18
|
+
<Stack gap="group">
|
|
19
|
+
<Stack gap="tight">
|
|
20
|
+
<Heading level={1} role="title">
|
|
21
|
+
Overview
|
|
22
|
+
</Heading>
|
|
23
|
+
<Text subtle>How the shop is doing, all time.</Text>
|
|
24
|
+
</Stack>
|
|
25
|
+
<Boundary
|
|
26
|
+
pending={<StatsLoading />}
|
|
27
|
+
error={(_, retry) => <Failed what="The totals" retry={retry} />}
|
|
28
|
+
>
|
|
29
|
+
<Stats />
|
|
30
|
+
</Boundary>
|
|
31
|
+
<Boundary
|
|
32
|
+
pending={<RevenueChartLoading />}
|
|
33
|
+
error={(_, retry) => <Failed what="The chart" retry={retry} />}
|
|
34
|
+
>
|
|
35
|
+
<RevenueChart />
|
|
36
|
+
</Boundary>
|
|
37
|
+
<Boundary
|
|
38
|
+
pending={<LatestOrdersLoading />}
|
|
39
|
+
error={(_, retry) => <Failed what="The latest orders" retry={retry} />}
|
|
40
|
+
>
|
|
41
|
+
<LatestOrders />
|
|
42
|
+
</Boundary>
|
|
43
|
+
</Stack>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { RouteProps } from '@weft-js/start'
|
|
2
|
+
import { Heading, Skeleton, Stack, Text } from '@weft-js/warp'
|
|
3
|
+
import { Boundary } from '@weft-js/core'
|
|
4
|
+
import { Failed } from '#/components/Failed'
|
|
5
|
+
import { count } from '#/lib/orders'
|
|
6
|
+
import { reach } from '#/server/reports'
|
|
7
|
+
|
|
8
|
+
function Reach() {
|
|
9
|
+
const store = reach()
|
|
10
|
+
return (
|
|
11
|
+
<Text subtle>
|
|
12
|
+
{count(store.value?.customers ?? 0)} customers across {count(store.value?.cities ?? 0)}{' '}
|
|
13
|
+
cities.
|
|
14
|
+
</Text>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function Regions(props: RouteProps) {
|
|
19
|
+
return (
|
|
20
|
+
<Stack gap="group">
|
|
21
|
+
<Stack gap="tight">
|
|
22
|
+
<Heading level={1} role="title">
|
|
23
|
+
Regions
|
|
24
|
+
</Heading>
|
|
25
|
+
<Boundary
|
|
26
|
+
pending={<Skeleton width="16rem" aria-label="Loading the reach" />}
|
|
27
|
+
error={(_, retry) => <Failed what="The reach" retry={retry} />}
|
|
28
|
+
>
|
|
29
|
+
<Reach />
|
|
30
|
+
</Boundary>
|
|
31
|
+
</Stack>
|
|
32
|
+
{props.children}
|
|
33
|
+
</Stack>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Meta } from '@weft-js/start'
|
|
2
|
+
import { Card, Grid, Heading, Skeleton, Stack, Table, Text, type Column } from '@weft-js/warp'
|
|
3
|
+
import { Boundary, derived } from '@weft-js/core'
|
|
4
|
+
import { Failed } from '#/components/Failed'
|
|
5
|
+
import { count, money } from '#/lib/orders'
|
|
6
|
+
import { biggestCustomers, topCities } from '#/server/reports'
|
|
7
|
+
|
|
8
|
+
export const meta: Meta = { title: 'Regions' }
|
|
9
|
+
|
|
10
|
+
type City = { city: string; orders: number; revenue: number }
|
|
11
|
+
|
|
12
|
+
const columns: Column<City>[] = [
|
|
13
|
+
{ id: 'city', header: 'City', rowHeader: true },
|
|
14
|
+
{ id: 'orders', header: 'Orders', numeric: true, value: (row) => count(row.orders) },
|
|
15
|
+
{ id: 'revenue', header: 'Revenue', numeric: true, value: (row) => money(row.revenue) },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
function Cities() {
|
|
19
|
+
const cities = topCities()
|
|
20
|
+
const rows = derived(() => cities.value ?? [])
|
|
21
|
+
return (
|
|
22
|
+
<Table
|
|
23
|
+
caption="Cities"
|
|
24
|
+
rows={rows}
|
|
25
|
+
columns={columns}
|
|
26
|
+
rowKey="city"
|
|
27
|
+
picker={false}
|
|
28
|
+
empty={{ title: 'No cities yet', text: 'Each city appears with its first order.' }}
|
|
29
|
+
/>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function BiggestCustomers() {
|
|
34
|
+
const biggest = biggestCustomers()
|
|
35
|
+
const people = derived(() => biggest.value ?? [])
|
|
36
|
+
return (
|
|
37
|
+
<div>
|
|
38
|
+
{people.value.length > 0 ? (
|
|
39
|
+
<Grid min="12rem" gap="related">
|
|
40
|
+
{people.value.map((person) => (
|
|
41
|
+
<Card key={person.id} eyebrow={person.city}>
|
|
42
|
+
<Stack gap="tight">
|
|
43
|
+
<strong>{person.name}</strong>
|
|
44
|
+
<Text subtle>{money(person.revenue)}</Text>
|
|
45
|
+
</Stack>
|
|
46
|
+
</Card>
|
|
47
|
+
))}
|
|
48
|
+
</Grid>
|
|
49
|
+
) : (
|
|
50
|
+
<Text subtle>Your best customers will show up here once orders come in.</Text>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Each part reads its own data inside a Boundary, so the page's first byte waits for none of them.
|
|
57
|
+
export default function RegionTable() {
|
|
58
|
+
return (
|
|
59
|
+
<Stack gap="group">
|
|
60
|
+
<Boundary
|
|
61
|
+
pending={<Skeleton lines={6} height="1.75rem" aria-label="Loading the cities" />}
|
|
62
|
+
error={(_, retry) => <Failed what="The cities" retry={retry} />}
|
|
63
|
+
>
|
|
64
|
+
<Cities />
|
|
65
|
+
</Boundary>
|
|
66
|
+
<Stack gap="related">
|
|
67
|
+
<Heading level={2}>Biggest customers</Heading>
|
|
68
|
+
<Boundary
|
|
69
|
+
pending={<Skeleton height="5.25rem" aria-label="Loading the biggest customers" />}
|
|
70
|
+
error={(_, retry) => <Failed what="The biggest customers" retry={retry} />}
|
|
71
|
+
>
|
|
72
|
+
<BiggestCustomers />
|
|
73
|
+
</Boundary>
|
|
74
|
+
</Stack>
|
|
75
|
+
</Stack>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { notFound, type LoadArgs, type Meta, type RouteProps } from '@weft-js/start'
|
|
2
|
+
import { Boundary } from '@weft-js/core'
|
|
3
|
+
import { Failed } from '#/components/Failed'
|
|
4
|
+
import { OrderDetail, OrderLoading } from '#/components/OrderDetail'
|
|
5
|
+
import { getOrder } from '#/server/orders'
|
|
6
|
+
|
|
7
|
+
export const load = async ({ params }: LoadArgs<'/admin/orders/[id]'>) => {
|
|
8
|
+
const id = Number(params.id)
|
|
9
|
+
if (!Number.isInteger(id) || id < 1) return notFound()
|
|
10
|
+
return (await getOrder.fetch({ id })) ? { id } : notFound()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const meta: Meta<typeof load> = ({ data }) => ({ title: `Order #${data.id}` })
|
|
14
|
+
|
|
15
|
+
export default function Order(props: RouteProps<typeof load, '/admin/orders/[id]'>) {
|
|
16
|
+
return (
|
|
17
|
+
<Boundary
|
|
18
|
+
pending={<OrderLoading />}
|
|
19
|
+
error={(_, retry) => <Failed what="This order" retry={retry} />}
|
|
20
|
+
>
|
|
21
|
+
<OrderDetail id={props.data.id} />
|
|
22
|
+
</Boundary>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { LoadArgs, Meta, RouteProps } from '@weft-js/start'
|
|
2
|
+
import { Heading, Stack } from '@weft-js/warp'
|
|
3
|
+
import { Boundary } from '@weft-js/core'
|
|
4
|
+
import { Failed } from '#/components/Failed'
|
|
5
|
+
import { OrdersFilters } from '#/components/OrdersFilters'
|
|
6
|
+
import { OrdersLoading, OrdersTable } from '#/components/OrdersTable'
|
|
7
|
+
import { viewOf } from '#/lib/orders'
|
|
8
|
+
|
|
9
|
+
export const load = ({ request }: LoadArgs) => ({ view: viewOf(request.url) })
|
|
10
|
+
|
|
11
|
+
export const meta: Meta = { title: 'Orders' }
|
|
12
|
+
|
|
13
|
+
export default function Orders(props: RouteProps<typeof load>) {
|
|
14
|
+
return (
|
|
15
|
+
<Stack gap="group">
|
|
16
|
+
<Heading level={1} role="title">
|
|
17
|
+
Orders
|
|
18
|
+
</Heading>
|
|
19
|
+
<OrdersFilters view={props.data.view} />
|
|
20
|
+
<Boundary
|
|
21
|
+
pending={<OrdersLoading />}
|
|
22
|
+
error={(_, retry) => <Failed what="The orders" retry={retry} />}
|
|
23
|
+
>
|
|
24
|
+
<OrdersTable view={props.data.view} />
|
|
25
|
+
</Boundary>
|
|
26
|
+
</Stack>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Meta } from '@weft-js/start'
|
|
2
|
+
import { Heading, Link, Skeleton, Stack, Table, Text, type Column } from '@weft-js/warp'
|
|
3
|
+
import { Boundary, derived } from '@weft-js/core'
|
|
4
|
+
import { Failed } from '#/components/Failed'
|
|
5
|
+
import { StatusBadge } from '#/components/StatusBadge'
|
|
6
|
+
import { date, money } from '#/lib/orders'
|
|
7
|
+
import { getCustomer, recentOrderIds } from '#/server/reports'
|
|
8
|
+
|
|
9
|
+
export const meta: Meta = { title: 'Order feed' }
|
|
10
|
+
|
|
11
|
+
type FeedRow = {
|
|
12
|
+
id: number
|
|
13
|
+
customerId: number
|
|
14
|
+
status: string
|
|
15
|
+
totalCents: number
|
|
16
|
+
createdAt: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function CustomerCell({ id }: { id: number }) {
|
|
20
|
+
const customer = getCustomer(() => ({ id }))
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
{customer.value?.name} <Text subtle>{customer.value?.city}</Text>
|
|
24
|
+
</>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const columns: Column<FeedRow>[] = [
|
|
29
|
+
{
|
|
30
|
+
id: 'id',
|
|
31
|
+
header: 'Order',
|
|
32
|
+
rowHeader: true,
|
|
33
|
+
value: (row) => `Order ${row.id}`,
|
|
34
|
+
cell: (row) => <Link href={`/admin/orders/${row.id}`}>#{row.id}</Link>,
|
|
35
|
+
},
|
|
36
|
+
{ id: 'customer', header: 'Customer', cell: (row) => <CustomerCell id={row.customerId} /> },
|
|
37
|
+
{
|
|
38
|
+
id: 'status',
|
|
39
|
+
header: 'Status',
|
|
40
|
+
card: 'badge',
|
|
41
|
+
cell: (row) => <StatusBadge status={row.status} />,
|
|
42
|
+
},
|
|
43
|
+
{ id: 'total', header: 'Total', numeric: true, value: (row) => money(row.totalCents) },
|
|
44
|
+
{ id: 'date', header: 'Date', value: (row) => date(row.createdAt) },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
function Feed() {
|
|
48
|
+
const recent = recentOrderIds()
|
|
49
|
+
const rows = derived(() => recent.value ?? [])
|
|
50
|
+
return (
|
|
51
|
+
<Table
|
|
52
|
+
caption="Recent orders"
|
|
53
|
+
captionHidden
|
|
54
|
+
rows={rows}
|
|
55
|
+
columns={columns}
|
|
56
|
+
picker={false}
|
|
57
|
+
empty={{ title: 'Quiet so far', text: 'New orders land here as they come in.' }}
|
|
58
|
+
/>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A plain box, not warp's Stack: there the Boundary would be part of an island that waits for a click, and a stream that stops
|
|
63
|
+
// early would leave the skeleton up. On its own the island runs as the page shows, and fills in from what the page carries.
|
|
64
|
+
export default function OrderFeed() {
|
|
65
|
+
return (
|
|
66
|
+
<div class="flow">
|
|
67
|
+
<Stack gap="tight">
|
|
68
|
+
<Heading level={1} role="title">
|
|
69
|
+
Order feed
|
|
70
|
+
</Heading>
|
|
71
|
+
<Text subtle>The last 50 orders, each with who placed it.</Text>
|
|
72
|
+
</Stack>
|
|
73
|
+
<Boundary
|
|
74
|
+
pending={<Skeleton lines={12} height="1.75rem" aria-label="Loading the feed" />}
|
|
75
|
+
error={(_, retry) => <Failed what="The feed" retry={retry} />}
|
|
76
|
+
>
|
|
77
|
+
<Feed />
|
|
78
|
+
</Boundary>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta } from '@weft-js/start'
|
|
2
|
+
import { Button, Heading, Skeleton, Stack, Table, Text, type Column } from '@weft-js/warp'
|
|
3
|
+
import { Boundary, derived, state } from '@weft-js/core'
|
|
4
|
+
import { Failed } from '#/components/Failed'
|
|
5
|
+
import { StatusBadge } from '#/components/StatusBadge'
|
|
6
|
+
import { count, money } from '#/lib/orders'
|
|
7
|
+
import { yearReport } from '#/server/reports'
|
|
8
|
+
|
|
9
|
+
export const meta: Meta = { title: 'Year report' }
|
|
10
|
+
|
|
11
|
+
type Line = { status: string; orders: number; revenue: number }
|
|
12
|
+
|
|
13
|
+
const columns: Column<Line>[] = [
|
|
14
|
+
{
|
|
15
|
+
id: 'status',
|
|
16
|
+
header: 'Status',
|
|
17
|
+
rowHeader: true,
|
|
18
|
+
cell: (row) => <StatusBadge status={row.status} />,
|
|
19
|
+
},
|
|
20
|
+
{ id: 'orders', header: 'Orders', numeric: true, value: (row) => count(row.orders) },
|
|
21
|
+
{ id: 'revenue', header: 'Revenue', numeric: true, value: (row) => money(row.revenue) },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
function Report() {
|
|
25
|
+
const report = yearReport()
|
|
26
|
+
const lines = derived(() => report.value ?? [])
|
|
27
|
+
return (
|
|
28
|
+
<Table
|
|
29
|
+
caption="The year by status"
|
|
30
|
+
captionHidden
|
|
31
|
+
rows={lines}
|
|
32
|
+
columns={columns}
|
|
33
|
+
rowKey="status"
|
|
34
|
+
picker={false}
|
|
35
|
+
empty={{ title: 'Nothing to report', text: 'The report fills in once orders come in.' }}
|
|
36
|
+
/>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Tally() {
|
|
41
|
+
const counted = state(0)
|
|
42
|
+
return (
|
|
43
|
+
<div class="section-head">
|
|
44
|
+
<span role="status">Count: {counted.value}</span>
|
|
45
|
+
<Button emphasis="outline" onClick={() => counted.value++}>
|
|
46
|
+
Count
|
|
47
|
+
</Button>
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default function YearReport() {
|
|
53
|
+
return (
|
|
54
|
+
<Stack gap="group">
|
|
55
|
+
<Stack gap="tight">
|
|
56
|
+
<Heading level={1} role="title">
|
|
57
|
+
Year report
|
|
58
|
+
</Heading>
|
|
59
|
+
<Text subtle>
|
|
60
|
+
The books take a moment to add up. Keep working: the page is yours while they do.
|
|
61
|
+
</Text>
|
|
62
|
+
</Stack>
|
|
63
|
+
<Tally />
|
|
64
|
+
<section aria-label="Report">
|
|
65
|
+
<Boundary
|
|
66
|
+
pending={<Skeleton lines={5} height="1.75rem" aria-label="Adding up the year" />}
|
|
67
|
+
error={(_, retry) => <Failed what="The report" retry={retry} />}
|
|
68
|
+
>
|
|
69
|
+
<Report />
|
|
70
|
+
</Boundary>
|
|
71
|
+
</section>
|
|
72
|
+
</Stack>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { redirect, type Meta } from '@weft-js/start'
|
|
2
|
+
|
|
3
|
+
// Only ever seen as a redirect, but a link to the bare domain still gets a title and a card.
|
|
4
|
+
export const meta: Meta = {
|
|
5
|
+
title: 'Backroom',
|
|
6
|
+
description: 'Backroom runs the shop: orders, customers and revenue in one place.',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const load = () => redirect('/admin')
|
|
10
|
+
|
|
11
|
+
export default function Home() {
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Form, Link, redirect, type FormApi, type LoadArgs, type Meta } from '@weft-js/start'
|
|
2
|
+
import { Alert, Button, Input, Stack, Text } from '@weft-js/warp'
|
|
3
|
+
import { AuthPage } from '#/components/AuthPage'
|
|
4
|
+
import { signIn } from '#/server/session'
|
|
5
|
+
|
|
6
|
+
export const load = ({ ctx }: LoadArgs) => {
|
|
7
|
+
if (ctx.user) redirect('/admin')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const meta: Meta = {
|
|
11
|
+
title: 'Sign in',
|
|
12
|
+
description: 'Backroom is where you run the shop: orders, customers and revenue in one place.',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function Login() {
|
|
16
|
+
return (
|
|
17
|
+
<AuthPage title="Back to work" lede="Sign in to see what came in overnight.">
|
|
18
|
+
<Form of={signIn}>
|
|
19
|
+
{(form: FormApi) => (
|
|
20
|
+
<Stack gap="related">
|
|
21
|
+
{form.message.value && (
|
|
22
|
+
<Alert tone="critical" title="We couldn't sign you in" live>
|
|
23
|
+
{form.message.value}
|
|
24
|
+
</Alert>
|
|
25
|
+
)}
|
|
26
|
+
<Input
|
|
27
|
+
label="Email"
|
|
28
|
+
type="email"
|
|
29
|
+
autocomplete="username"
|
|
30
|
+
required
|
|
31
|
+
{...form.field('email', 'email')}
|
|
32
|
+
/>
|
|
33
|
+
<form.Error name="email" class="form-error" />
|
|
34
|
+
<Input
|
|
35
|
+
label="Password"
|
|
36
|
+
type="password"
|
|
37
|
+
name="password"
|
|
38
|
+
autocomplete="current-password"
|
|
39
|
+
required
|
|
40
|
+
/>
|
|
41
|
+
<form.Error name="password" class="form-error" />
|
|
42
|
+
<Button type="submit" full loading={form.pending.value}>
|
|
43
|
+
Sign in
|
|
44
|
+
</Button>
|
|
45
|
+
</Stack>
|
|
46
|
+
)}
|
|
47
|
+
</Form>
|
|
48
|
+
<Text subtle>
|
|
49
|
+
Setting up a new store? <Link href="/signup">Create the owner account</Link>
|
|
50
|
+
</Text>
|
|
51
|
+
</AuthPage>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Form, Link, redirect, type FormApi, type LoadArgs, type Meta } from '@weft-js/start'
|
|
2
|
+
import { Alert, Button, Input, Stack, Text } from '@weft-js/warp'
|
|
3
|
+
import { AuthPage } from '#/components/AuthPage'
|
|
4
|
+
import { signUp } from '#/server/session'
|
|
5
|
+
|
|
6
|
+
export const load = ({ ctx }: LoadArgs) => {
|
|
7
|
+
if (ctx.user) redirect('/admin')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const meta: Meta = {
|
|
11
|
+
title: 'Create the owner account',
|
|
12
|
+
description: 'The first account owns the store. Sign-up closes after it.',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function Signup() {
|
|
16
|
+
return (
|
|
17
|
+
<AuthPage
|
|
18
|
+
title="Open up the backroom"
|
|
19
|
+
lede="The first account owns the store. Sign-up closes after it."
|
|
20
|
+
>
|
|
21
|
+
<Form of={signUp}>
|
|
22
|
+
{(form: FormApi) => (
|
|
23
|
+
<Stack gap="related">
|
|
24
|
+
{form.message.value && (
|
|
25
|
+
<Alert tone="critical" title="We couldn't create your account" live>
|
|
26
|
+
{form.message.value}
|
|
27
|
+
</Alert>
|
|
28
|
+
)}
|
|
29
|
+
<Input label="Name" autocomplete="name" required {...form.field('name', 'text')} />
|
|
30
|
+
<form.Error name="name" class="form-error" />
|
|
31
|
+
<Input
|
|
32
|
+
label="Email"
|
|
33
|
+
type="email"
|
|
34
|
+
autocomplete="username"
|
|
35
|
+
required
|
|
36
|
+
{...form.field('email', 'email')}
|
|
37
|
+
/>
|
|
38
|
+
<form.Error name="email" class="form-error" />
|
|
39
|
+
<Input
|
|
40
|
+
label="Password"
|
|
41
|
+
type="password"
|
|
42
|
+
name="password"
|
|
43
|
+
autocomplete="new-password"
|
|
44
|
+
required
|
|
45
|
+
minlength={8}
|
|
46
|
+
hint="At least 8 characters."
|
|
47
|
+
/>
|
|
48
|
+
<form.Error name="password" class="form-error" />
|
|
49
|
+
<Button type="submit" full loading={form.pending.value}>
|
|
50
|
+
Create account
|
|
51
|
+
</Button>
|
|
52
|
+
</Stack>
|
|
53
|
+
)}
|
|
54
|
+
</Form>
|
|
55
|
+
<Text subtle>
|
|
56
|
+
Already have an account? <Link href="/login">Sign in</Link>
|
|
57
|
+
</Text>
|
|
58
|
+
</AuthPage>
|
|
59
|
+
)
|
|
60
|
+
}
|