@weft-js/create 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,65 @@
1
+ import { Grid, Heading, Link, Stack, Text } from '@weft-js/warp'
2
+ import { derived } from '@weft-js/core'
3
+ import { ProductCard } from '#/components/ProductCard'
4
+ import { PAGE_SIZE, viewHref, type View } from '#/lib/catalog'
5
+ import { listProducts } from '#/server/catalog'
6
+
7
+ export function ProductList({
8
+ categoryId,
9
+ slug,
10
+ view,
11
+ }: {
12
+ categoryId: number
13
+ slug: string
14
+ view: View
15
+ }) {
16
+ const products = listProducts(() => ({ categoryId, ...view }))
17
+ const rows = derived(() => products.value?.rows ?? [])
18
+ const total = derived(() => products.value?.total ?? 0)
19
+ const pages = derived(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
20
+ const filtered = () => view.min !== null || view.max !== null || view.stock
21
+ return (
22
+ <Stack gap="group">
23
+ <Text subtle aria-live="polite">
24
+ {total.value.toLocaleString('en-US')} {total.value === 1 ? 'piece' : 'pieces'}, lowest price
25
+ first
26
+ </Text>
27
+ {rows.value.length === 0 ? (
28
+ <Stack gap="tight" class="blank">
29
+ <Heading level={2}>
30
+ {filtered() ? 'Nothing fits those filters' : 'This aisle is empty for now'}
31
+ </Heading>
32
+ <Text subtle>
33
+ {filtered()
34
+ ? "Widen the price range, or include what's sold out."
35
+ : "New pieces land here as soon as they're made."}
36
+ </Text>
37
+ {filtered() && <Link href={`/c/${slug}`}>Clear the filters</Link>}
38
+ </Stack>
39
+ ) : (
40
+ <Grid min="xs" cols={3} gap="group">
41
+ {rows.value.map((product) => (
42
+ <ProductCard key={product.slug} product={product} />
43
+ ))}
44
+ </Grid>
45
+ )}
46
+ {pages.value > 1 && (
47
+ <nav class="pager" aria-label="Pages">
48
+ {view.page > 1 ? (
49
+ <Link href={viewHref(slug, { ...view, page: view.page - 1 })}>Previous</Link>
50
+ ) : (
51
+ <span aria-disabled="true">Previous</span>
52
+ )}
53
+ <Text subtle>
54
+ Page {view.page} of {pages.value}
55
+ </Text>
56
+ {view.page < pages.value ? (
57
+ <Link href={viewHref(slug, { ...view, page: view.page + 1 })}>Next</Link>
58
+ ) : (
59
+ <span aria-disabled="true">Next</span>
60
+ )}
61
+ </nav>
62
+ )}
63
+ </Stack>
64
+ )
65
+ }
@@ -0,0 +1,19 @@
1
+ import { Grid, Text } from '@weft-js/warp'
2
+ import { ProductCard } from '#/components/ProductCard'
3
+ import { relatedProducts } from '#/server/catalog'
4
+
5
+ export function Related({ id, categoryId }: { id: number; categoryId: number }) {
6
+ const related = relatedProducts(() => ({ id, categoryId }))
7
+ return (
8
+ <>
9
+ {related.value?.length === 0 && (
10
+ <Text subtle>This is the only piece in its aisle for now.</Text>
11
+ )}
12
+ <Grid min="xs" cols={4} gap="group">
13
+ {related.value?.map((product) => (
14
+ <ProductCard key={product.slug} product={product} />
15
+ ))}
16
+ </Grid>
17
+ </>
18
+ )
19
+ }
@@ -0,0 +1,9 @@
1
+ // Plain elements, like the header: nothing here runs, so nothing here has to ship.
2
+ export function SiteFooter() {
3
+ return (
4
+ <footer class="site-foot">
5
+ <p>Mendwell. Goods made to be used hard and fixed when they wear.</p>
6
+ <p>Free repairs for the first ten years.</p>
7
+ </footer>
8
+ )
9
+ }
@@ -0,0 +1,16 @@
1
+ import { Link } from '@weft-js/start'
2
+ import { CartLink } from '#/components/CartLink'
3
+ import { Logo } from '#/components/Logo'
4
+
5
+ // Plain elements, not warp's Center and Row: wrapped in those, the cart count becomes part of an island that waits for a click.
6
+ export function SiteHeader() {
7
+ return (
8
+ <header class="site-head">
9
+ <Link href="/" class="brand">
10
+ <Logo />
11
+ Mendwell
12
+ </Link>
13
+ <CartLink />
14
+ </header>
15
+ )
16
+ }
@@ -0,0 +1,53 @@
1
+ import {
2
+ boolean,
3
+ index,
4
+ integer,
5
+ pgTable,
6
+ primaryKey,
7
+ serial,
8
+ text,
9
+ timestamp,
10
+ uuid,
11
+ } from 'drizzle-orm/pg-core'
12
+
13
+ export const categories = pgTable('categories', {
14
+ id: serial('id').primaryKey(),
15
+ slug: text('slug').notNull().unique(),
16
+ name: text('name').notNull(),
17
+ })
18
+
19
+ export const products = pgTable(
20
+ 'products',
21
+ {
22
+ id: serial('id').primaryKey(),
23
+ slug: text('slug').notNull().unique(),
24
+ name: text('name').notNull(),
25
+ description: text('description').notNull(),
26
+ categoryId: integer('category_id')
27
+ .notNull()
28
+ .references(() => categories.id),
29
+ priceCents: integer('price_cents').notNull(),
30
+ stock: integer('stock').notNull(),
31
+ featured: boolean('featured').notNull().default(false),
32
+ },
33
+ (table) => [index('products_category').on(table.categoryId, table.priceCents, table.id)],
34
+ )
35
+
36
+ export const carts = pgTable('carts', {
37
+ id: uuid('id').primaryKey().defaultRandom(),
38
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
39
+ })
40
+
41
+ export const cartItems = pgTable(
42
+ 'cart_items',
43
+ {
44
+ cartId: uuid('cart_id')
45
+ .notNull()
46
+ .references(() => carts.id, { onDelete: 'cascade' }),
47
+ productId: integer('product_id')
48
+ .notNull()
49
+ .references(() => products.id),
50
+ quantity: integer('quantity').notNull(),
51
+ },
52
+ (table) => [primaryKey({ columns: [table.cartId, table.productId] })],
53
+ )
@@ -0,0 +1,35 @@
1
+ export const PAGE_SIZE = 24
2
+ /** Prices are filtered in whole dollars; past this nothing is for sale, and cents still fit the column. */
3
+ const MOST = 1_000_000
4
+
5
+ /** A category page's filters: everything lives in the URL, so a filtered page can be shared and works before hydration. */
6
+ export type View = { min: number | null; max: number | null; stock: boolean; page: number }
7
+
8
+ export const amount = (text: unknown) => {
9
+ const n = typeof text === 'string' && text.trim() ? Number(text) : NaN
10
+ return n >= 0 ? Math.min(Math.floor(n), MOST) : null
11
+ }
12
+
13
+ export function viewOf(url: string): View {
14
+ const q = new URL(url).searchParams
15
+ const page = Number(q.get('page'))
16
+ return {
17
+ min: amount(q.get('min')),
18
+ max: amount(q.get('max')),
19
+ stock: q.has('stock'),
20
+ page: Number.isSafeInteger(page) && page > 1 && page < 1e6 ? page : 1,
21
+ }
22
+ }
23
+
24
+ export function viewHref(slug: string, view: View) {
25
+ const q = new URLSearchParams()
26
+ if (view.min !== null) q.set('min', String(view.min))
27
+ if (view.max !== null) q.set('max', String(view.max))
28
+ if (view.stock) q.set('stock', '1')
29
+ if (view.page > 1) q.set('page', String(view.page))
30
+ const search = q.toString()
31
+ return `/c/${slug}${search ? `?${search}` : ''}`
32
+ }
33
+
34
+ const dollars = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
35
+ export const money = (cents: number) => dollars.format(cents / 100)
@@ -0,0 +1,18 @@
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: 'Not on the shelves' }
5
+
6
+ export default function NotFound() {
7
+ return (
8
+ <Center max="narrow" gutter>
9
+ <Stack gap="group" align="center" class="system-page">
10
+ <Heading level={1} role="title">
11
+ Not on the shelves
12
+ </Heading>
13
+ <Text subtle>That piece may be gone, or the address mistyped.</Text>
14
+ <Link href="/">Back to the aisles</Link>
15
+ </Stack>
16
+ </Center>
17
+ )
18
+ }
@@ -0,0 +1,18 @@
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
+ <Center max="narrow" gutter>
9
+ <Stack gap="group" align="center" class="system-page">
10
+ <Heading level={1} role="title">
11
+ Something broke on our bench
12
+ </Heading>
13
+ <Text subtle>Your cart is safe. Give it a minute and try again.</Text>
14
+ <Link href="/">Back to the aisles</Link>
15
+ </Stack>
16
+ </Center>
17
+ )
18
+ }
@@ -0,0 +1,14 @@
1
+ import '../theme.css'
2
+ import type { RouteProps } from '@weft-js/start'
3
+ import { SiteFooter } from '#/components/SiteFooter'
4
+ import { SiteHeader } from '#/components/SiteHeader'
5
+
6
+ export default function Root(props: RouteProps) {
7
+ return (
8
+ <div class="site">
9
+ <SiteHeader />
10
+ <main id="content">{props.children}</main>
11
+ <SiteFooter />
12
+ </div>
13
+ )
14
+ }
@@ -0,0 +1,53 @@
1
+ import { Link, notFound, type LoadArgs, type Meta, type RouteProps } from '@weft-js/start'
2
+ import { Center, Heading, Split, Stack } from '@weft-js/warp'
3
+ import { Boundary } from '@weft-js/core'
4
+ import { Aisles, AislesLoading } from '#/components/Aisles'
5
+ import { Failed } from '#/components/Failed'
6
+ import { Filters } from '#/components/Filters'
7
+ import { ProductGridLoading } from '#/components/ProductCard'
8
+ import { ProductList } from '#/components/ProductList'
9
+ import { viewOf } from '#/lib/catalog'
10
+ import { getCategory } from '#/server/catalog'
11
+
12
+ export const load = async ({ params, request }: LoadArgs<'/c/[slug]'>) => {
13
+ const category = await getCategory.fetch({ slug: params.slug })
14
+ return category ? { category, view: viewOf(request.url) } : notFound()
15
+ }
16
+
17
+ export const meta: Meta<typeof load> = ({ data }) => ({ title: data.category.name })
18
+
19
+ export default function Category(props: RouteProps<typeof load, '/c/[slug]'>) {
20
+ return (
21
+ <Center max="wide" gutter>
22
+ <Stack gap="group" class="page">
23
+ <Stack gap="tight">
24
+ <Link href="/" class="crumb">
25
+ All aisles
26
+ </Link>
27
+ <Heading level={1} role="title">
28
+ {props.data.category.name}
29
+ </Heading>
30
+ </Stack>
31
+ <Boundary
32
+ pending={<AislesLoading />}
33
+ error={(_, retry) => <Failed what="The aisles" retry={retry} />}
34
+ >
35
+ <Aisles current={props.data.category.slug} />
36
+ </Boundary>
37
+ <Split side="start" width="nav" gap="section">
38
+ <Filters slug={props.data.category.slug} view={props.data.view} />
39
+ <Boundary
40
+ pending={<ProductGridLoading count={24} cols={3} label="Loading goods" />}
41
+ error={(_, retry) => <Failed what="This aisle" retry={retry} />}
42
+ >
43
+ <ProductList
44
+ categoryId={props.data.category.id}
45
+ slug={props.data.category.slug}
46
+ view={props.data.view}
47
+ />
48
+ </Boundary>
49
+ </Split>
50
+ </Stack>
51
+ </Center>
52
+ )
53
+ }
@@ -0,0 +1,29 @@
1
+ import type { Meta } from '@weft-js/start'
2
+ import { Center, Heading, Stack, Toaster } from '@weft-js/warp'
3
+ import { Boundary } from '@weft-js/core'
4
+ import { CartLines, CartLoading } from '#/components/CartLines'
5
+ import { Failed } from '#/components/Failed'
6
+
7
+ export const meta: Meta = {
8
+ title: 'Your cart',
9
+ description: 'Review the Mendwell goods in your cart.',
10
+ }
11
+
12
+ export default function Cart() {
13
+ return (
14
+ <Center max="content" gutter>
15
+ <Stack gap="group" class="page">
16
+ <Heading level={1} role="title">
17
+ Your cart
18
+ </Heading>
19
+ <Boundary
20
+ pending={<CartLoading />}
21
+ error={(_, retry) => <Failed what="Your cart" retry={retry} />}
22
+ >
23
+ <CartLines />
24
+ </Boundary>
25
+ </Stack>
26
+ <Toaster />
27
+ </Center>
28
+ )
29
+ }
@@ -0,0 +1,84 @@
1
+ import { Link, type Meta } from '@weft-js/start'
2
+ import { Center, Grid, Heading, Stack, Text } from '@weft-js/warp'
3
+ import { Boundary } from '@weft-js/core'
4
+ import { Aisles, AislesLoading } from '#/components/Aisles'
5
+ import { Failed } from '#/components/Failed'
6
+ import { Featured } from '#/components/Featured'
7
+ import { ProductArt } from '#/components/ProductArt'
8
+ import { ProductGridLoading } from '#/components/ProductCard'
9
+
10
+ export const meta: Meta = {
11
+ title: 'Goods made to be mended',
12
+ description: 'Explore everyday goods made to be repaired and used for years.',
13
+ }
14
+
15
+ export default function Home() {
16
+ return (
17
+ <Center max="wide" gutter>
18
+ <section class="hero">
19
+ <Stack gap="related">
20
+ {/* weft-allow D006: the storefront's one poster headline */}
21
+ <Heading level={1} role="display">
22
+ Buy it once. Mend it forever.
23
+ </Heading>
24
+ <Text class="lede">
25
+ Everyday goods in oak, copper, linen and slate, built with screws instead of glue, and
26
+ repaired free for ten years.
27
+ </Text>
28
+ <Link href="#featured" class="cta">
29
+ See this month's table
30
+ </Link>
31
+ </Stack>
32
+ <div class="hero-art">
33
+ <ProductArt name="Copper Kettle" />
34
+ <ProductArt name="Oak Stool" />
35
+ <ProductArt name="Slate Lamp" />
36
+ </div>
37
+ </section>
38
+ <Stack gap="page">
39
+ <section aria-labelledby="aisles-title">
40
+ <Stack gap="related">
41
+ <Heading level={2} id="aisles-title">
42
+ Aisles
43
+ </Heading>
44
+ <Boundary
45
+ pending={<AislesLoading />}
46
+ error={(_, retry) => <Failed what="The aisles" retry={retry} />}
47
+ >
48
+ <Aisles />
49
+ </Boundary>
50
+ </Stack>
51
+ </section>
52
+ <section id="featured" aria-labelledby="featured-title">
53
+ <Stack gap="related">
54
+ <Heading level={2} id="featured-title">
55
+ On the front table
56
+ </Heading>
57
+ <Boundary
58
+ pending={<ProductGridLoading count={12} cols={4} label="Loading featured goods" />}
59
+ error={(_, retry) => <Failed what="The front table" retry={retry} />}
60
+ >
61
+ <Featured />
62
+ </Boundary>
63
+ </Stack>
64
+ </section>
65
+ <Grid min="md" cols={3} gap="group" class="promises">
66
+ <Stack gap="tight">
67
+ <Heading level={3}>Screws, not glue</Heading>
68
+ <Text subtle>Every piece comes apart with a screwdriver you already own.</Text>
69
+ </Stack>
70
+ <Stack gap="tight">
71
+ <Heading level={3}>Parts on the shelf</Heading>
72
+ <Text subtle>
73
+ Handles, hinges and cords are stocked for as long as we sell the piece.
74
+ </Text>
75
+ </Stack>
76
+ <Stack gap="tight">
77
+ <Heading level={3}>Repairs by post</Heading>
78
+ <Text subtle>Send it in, and it comes back working. Free for ten years.</Text>
79
+ </Stack>
80
+ </Grid>
81
+ </Stack>
82
+ </Center>
83
+ )
84
+ }
@@ -0,0 +1,67 @@
1
+ import { Link, notFound, type LoadArgs, type Meta, type RouteProps } from '@weft-js/start'
2
+ import { Badge, Center, Heading, Row, Stack, Text } from '@weft-js/warp'
3
+ import { Boundary } from '@weft-js/core'
4
+ import { AddToCart } from '#/components/AddToCart'
5
+ import { Failed } from '#/components/Failed'
6
+ import { ProductArt } from '#/components/ProductArt'
7
+ import { ProductGridLoading } from '#/components/ProductCard'
8
+ import { Related } from '#/components/Related'
9
+ import { money } from '#/lib/catalog'
10
+ import { getProduct } from '#/server/catalog'
11
+
12
+ export const load = async ({ params }: LoadArgs<'/p/[slug]'>) => {
13
+ const product = await getProduct.fetch({ slug: params.slug })
14
+ return product ? { product } : notFound()
15
+ }
16
+
17
+ export const meta: Meta<typeof load> = ({ data }) => ({
18
+ title: data.product.name,
19
+ description: data.product.description,
20
+ })
21
+
22
+ const stockLine = (stock: number) =>
23
+ stock === 0
24
+ ? 'Back when the workshop catches up.'
25
+ : stock < 5
26
+ ? `Only ${stock} left.`
27
+ : 'In stock, ships in two days.'
28
+
29
+ export default function Product(props: RouteProps<typeof load, '/p/[slug]'>) {
30
+ return (
31
+ <Center max="wide" gutter>
32
+ <Stack gap="page" class="page">
33
+ <article class="detail">
34
+ <ProductArt name={props.data.product.name} />
35
+ <Stack gap="related">
36
+ <Link href={`/c/${props.data.product.categorySlug}`} class="crumb">
37
+ {props.data.product.categoryName}
38
+ </Link>
39
+ <Heading level={1} role="title">
40
+ {props.data.product.name}
41
+ </Heading>
42
+ <Row gap="related">
43
+ <span class="price price-lg">{money(props.data.product.priceCents)}</span>
44
+ {props.data.product.stock === 0 && <Badge tone="neutral">Sold out</Badge>}
45
+ </Row>
46
+ <Text>{props.data.product.description}</Text>
47
+ <Text subtle>{stockLine(props.data.product.stock)}</Text>
48
+ <AddToCart slug={props.data.product.slug} soldOut={props.data.product.stock === 0} />
49
+ </Stack>
50
+ </article>
51
+ <section aria-labelledby="related-title">
52
+ <Stack gap="related">
53
+ <Heading level={2} id="related-title">
54
+ From the same aisle
55
+ </Heading>
56
+ <Boundary
57
+ pending={<ProductGridLoading count={4} cols={4} label="Loading related goods" />}
58
+ error={(_, retry) => <Failed what="Related goods" retry={retry} />}
59
+ >
60
+ <Related id={props.data.product.id} categoryId={props.data.product.categoryId} />
61
+ </Boundary>
62
+ </Stack>
63
+ </section>
64
+ </Stack>
65
+ </Center>
66
+ )
67
+ }
@@ -0,0 +1,125 @@
1
+ import { invalid, mutation, query, type Context } from '@weft-js/start'
2
+ import { and, asc, eq, sql } from 'drizzle-orm'
3
+ import { z } from 'zod'
4
+ import { cartItems, carts, products } from '#/db/schema'
5
+
6
+ const COOKIE = 'cart'
7
+ const SIXTY_DAYS = 60 * 60 * 24 * 60
8
+ const MOST = 99
9
+
10
+ /** This visitor's cart. One this same request started is only in the response so far, and the refreshed count has to find it. */
11
+ function cartOf(ctx: Context) {
12
+ const cookies = [
13
+ ...ctx.response.headers.getSetCookie(),
14
+ ctx.request.headers.get('cookie') ?? '',
15
+ ].join('; ')
16
+ return cookies.match(
17
+ /(?:^|;\s*)cart=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/,
18
+ )?.[1]
19
+ }
20
+
21
+ function startCart(ctx: Context) {
22
+ const id = crypto.randomUUID()
23
+ const secure = new URL(ctx.request.url).protocol === 'https:' ? '; Secure' : ''
24
+ ctx.response.headers.append(
25
+ 'set-cookie',
26
+ `${COOKIE}=${id}; Path=/; Max-Age=${SIXTY_DAYS}; HttpOnly; SameSite=Lax${secure}`,
27
+ )
28
+ return id
29
+ }
30
+
31
+ export const cartCount = query({
32
+ tags: ['cart_items'],
33
+ run: async ({ ctx }) => {
34
+ const cart = cartOf(ctx)
35
+ if (!cart) return { count: 0 }
36
+ const [row] = await ctx.db
37
+ .select({ count: sql<number>`coalesce(sum(${cartItems.quantity}), 0)`.mapWith(Number) })
38
+ .from(cartItems)
39
+ .where(eq(cartItems.cartId, cart))
40
+ return { count: row!.count }
41
+ },
42
+ })
43
+
44
+ export const listCart = query({
45
+ tags: ['cart_items', 'products'],
46
+ run: async ({ ctx }) => {
47
+ const cart = cartOf(ctx)
48
+ if (!cart) return []
49
+ return await ctx.db
50
+ .select({
51
+ slug: products.slug,
52
+ name: products.name,
53
+ priceCents: products.priceCents,
54
+ stock: products.stock,
55
+ quantity: cartItems.quantity,
56
+ })
57
+ .from(cartItems)
58
+ .innerJoin(products, eq(products.id, cartItems.productId))
59
+ .where(eq(cartItems.cartId, cart))
60
+ .orderBy(asc(products.name), asc(products.id))
61
+ },
62
+ })
63
+
64
+ export const addToCart = mutation({
65
+ input: z.object({ slug: z.string().min(1).max(200) }),
66
+ invalidates: ['carts', 'cart_items'],
67
+ // Not optimistic: a first add's cart only exists once its cookie lands, so the count waits for the reply, not the click.
68
+ run: async ({ input, ctx }) => {
69
+ const [product] = await ctx.db
70
+ .select({ id: products.id, stock: products.stock })
71
+ .from(products)
72
+ .where(eq(products.slug, input.slug))
73
+ if (!product?.stock) invalid({ slug: 'That one just sold out.' })
74
+ const cart = cartOf(ctx) ?? startCart(ctx)
75
+ // The cookie can outlive the row, so the cart is made sure of on every add.
76
+ await ctx.db.insert(carts).values({ id: cart }).onConflictDoNothing()
77
+ await ctx.db
78
+ .insert(cartItems)
79
+ .values({ cartId: cart, productId: product!.id, quantity: 1 })
80
+ .onConflictDoUpdate({
81
+ target: [cartItems.cartId, cartItems.productId],
82
+ set: { quantity: sql`least(${cartItems.quantity} + 1, ${Math.min(product!.stock, MOST)})` },
83
+ })
84
+ return { added: true }
85
+ },
86
+ })
87
+
88
+ // `quantity` is the number asked for, not a step, so a repeated press lands in the same place. Zero takes the line out.
89
+ export const setQuantity = mutation({
90
+ input: z.object({
91
+ slug: z.string().min(1).max(200),
92
+ quantity: z.number().int().min(0).max(MOST),
93
+ }),
94
+ invalidates: ['cart_items'],
95
+ optimistic: ({ input }, cache) => {
96
+ let change = 0
97
+ cache.update(listCart, undefined, (lines) => {
98
+ const at = lines.findIndex((line) => line.slug === input.slug)
99
+ if (at < 0) return
100
+ change = input.quantity - lines[at]!.quantity
101
+ if (input.quantity) lines[at]!.quantity = input.quantity
102
+ else lines.splice(at, 1)
103
+ })
104
+ if (cache.get(cartCount))
105
+ cache.update(cartCount, undefined, (cart) => {
106
+ cart.count += change
107
+ })
108
+ },
109
+ run: async ({ input, ctx }) => {
110
+ const cart = cartOf(ctx)
111
+ if (!cart) return
112
+ const [product] = await ctx.db
113
+ .select({ id: products.id, stock: products.stock })
114
+ .from(products)
115
+ .where(eq(products.slug, input.slug))
116
+ if (!product) return
117
+ const line = and(eq(cartItems.cartId, cart), eq(cartItems.productId, product.id))
118
+ if (input.quantity)
119
+ await ctx.db
120
+ .update(cartItems)
121
+ .set({ quantity: Math.min(input.quantity, product.stock) })
122
+ .where(line)
123
+ else await ctx.db.delete(cartItems).where(line)
124
+ },
125
+ })