create-top-secret-starter 0.12.0 → 0.13.0
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/backend-overlay/remove.json +5 -0
- package/backend-overlay/root/_gitignore +2 -0
- package/backend-overlay/web/src/api/index.ts +11 -1
- package/backend-overlay/web/src/api/live/index.ts +97 -0
- package/backend-overlay/web/src/api/orders/index.ts +65 -0
- package/backend-overlay/web/src/components/header-actions.tsx +30 -0
- package/backend-overlay/web/src/lib/query-client.test.tsx +22 -0
- package/backend-overlay/web/src/lib/query-client.ts +31 -11
- package/backend-overlay/web/src/routes/_authenticated/index.tsx +19 -54
- package/backend-overlay/web/src/routes/_authenticated/notes/index.tsx +65 -0
- package/backend-overlay/web/src/routes/_authenticated.tsx +1 -1
- package/backend-overlay/web-router/src/routes/sign-in.tsx +1 -1
- package/backend-overlay/web-router/src/routes/sign-up.tsx +1 -1
- package/backend-overlay/web-start/src/api/index.ts +11 -1
- package/backend-overlay/web-start/src/routes/sign-in.tsx +1 -1
- package/backend-overlay/web-start/src/routes/sign-up.tsx +1 -1
- package/features.json +51 -0
- package/index.js +73 -44
- package/markers.js +37 -0
- package/package.json +4 -2
- package/templates/template-api/CLAUDE.md +6 -0
- package/templates/template-api/drizzle/0002_dear_warstar.sql +15 -0
- package/templates/template-api/drizzle/meta/0002_snapshot.json +585 -0
- package/templates/template-api/drizzle/meta/_journal.json +7 -0
- package/templates/template-api/src/app.test.ts +175 -3
- package/templates/template-api/src/app.ts +35 -5
- package/templates/template-api/src/db/schema.ts +36 -0
- package/templates/template-api/src/index.ts +21 -1
- package/templates/template-api/src/modules/live/hub.ts +123 -0
- package/templates/template-api/src/modules/live/index.ts +30 -0
- package/templates/template-api/src/modules/live/model.ts +31 -0
- package/templates/template-api/src/modules/notes/index.ts +1 -1
- package/templates/template-api/src/modules/orders/index.ts +71 -0
- package/templates/template-api/src/modules/orders/model.ts +49 -0
- package/templates/template-api/src/modules/orders/service.ts +78 -0
- package/templates/template-router/.claude/settings.json +1 -1
- package/templates/template-router/.claude/skills/project-conventions/SKILL.md +40 -30
- package/templates/template-router/.oxfmtrc.json +1 -0
- package/templates/template-router/.oxlintrc.json +18 -8
- package/templates/template-router/CLAUDE.md +3 -5
- package/templates/template-router/_gitignore +2 -0
- package/templates/template-router/doctor.config.json +4 -0
- package/templates/template-router/e2e/dashboard.spec.ts +38 -0
- package/templates/template-router/e2e/keyboard.spec.ts +18 -8
- package/templates/template-router/e2e/orders.spec.ts +82 -0
- package/templates/template-router/e2e/utils.ts +1 -2
- package/templates/template-router/index.html +11 -8
- package/templates/template-router/package.json +11 -7
- package/templates/template-router/playwright.config.ts +15 -23
- package/templates/template-router/src/api/auth/session-store.test.ts +14 -4
- package/templates/template-router/src/api/index.ts +59 -28
- package/templates/template-router/src/api/live/feed.ts +14 -0
- package/templates/template-router/src/api/live/index.ts +105 -0
- package/templates/template-router/src/api/live/live.test.ts +67 -0
- package/templates/template-router/src/api/live/schema.ts +33 -0
- package/templates/template-router/src/api/orders/index.ts +63 -0
- package/templates/template-router/src/api/orders/input.ts +48 -0
- package/templates/template-router/src/api/orders/orders.test.ts +48 -0
- package/templates/template-router/src/api/orders/schema.ts +20 -0
- package/templates/template-router/src/api/refresh.test.ts +62 -0
- package/templates/template-router/src/components/app-shell.plain.tsx +29 -0
- package/templates/template-router/src/components/app-shell.tsx +24 -0
- package/templates/template-router/src/components/app-sidebar.tsx +74 -0
- package/templates/template-router/src/components/dashboard/activity-feed.tsx +42 -0
- package/templates/template-router/src/components/dashboard/dashboard.tsx +88 -0
- package/templates/template-router/src/components/dashboard/live-badge.tsx +23 -0
- package/templates/template-router/src/components/dashboard/revenue-chart.tsx +82 -0
- package/templates/template-router/src/components/dashboard/stat-tile.tsx +33 -0
- package/templates/template-router/src/components/error-screen.tsx +2 -2
- package/templates/template-router/src/components/header-actions.tsx +28 -0
- package/templates/template-router/src/components/nav.ts +17 -0
- package/templates/template-router/src/components/orders/create-order-dialog.tsx +114 -0
- package/templates/template-router/src/components/orders/order-status-badge.tsx +28 -0
- package/templates/template-router/src/components/orders/order-status-select.tsx +59 -0
- package/templates/template-router/src/components/orders/order-status.ts +11 -0
- package/templates/template-router/src/components/orders/orders-table.tsx +223 -0
- package/templates/template-router/src/components/root-error-boundary.tsx +3 -7
- package/templates/template-router/src/components/ui/badge.tsx +48 -0
- package/templates/template-router/src/components/ui/button.tsx +2 -2
- package/templates/template-router/src/components/ui/card.tsx +87 -0
- package/templates/template-router/src/components/ui/chart.tsx +338 -0
- package/templates/template-router/src/components/ui/dialog.tsx +136 -0
- package/templates/template-router/src/components/ui/dropdown-menu.tsx +255 -0
- package/templates/template-router/src/components/ui/empty.tsx +2 -2
- package/templates/template-router/src/components/ui/field.tsx +6 -6
- package/templates/template-router/src/components/ui/input.tsx +1 -1
- package/templates/template-router/src/components/ui/select.tsx +189 -0
- package/templates/template-router/src/components/ui/separator.tsx +1 -1
- package/templates/template-router/src/components/ui/sheet.tsx +125 -0
- package/templates/template-router/src/components/ui/sidebar.tsx +682 -0
- package/templates/template-router/src/components/ui/skeleton.tsx +13 -0
- package/templates/template-router/src/components/ui/table.tsx +86 -0
- package/templates/template-router/src/components/ui/textarea.tsx +1 -1
- package/templates/template-router/src/components/ui/toast.tsx +3 -3
- package/templates/template-router/src/components/ui/tooltip.tsx +2 -2
- package/templates/template-router/src/hooks/use-mobile.ts +18 -0
- package/templates/template-router/src/index.css +0 -7
- package/templates/template-router/src/lib/format.ts +39 -0
- package/templates/template-router/src/lib/query-client.test.tsx +21 -10
- package/templates/template-router/src/lib/query-client.ts +26 -15
- package/templates/template-router/src/lib/sidebar-store.ts +10 -0
- package/templates/template-router/src/lib/single-flight.test.ts +14 -3
- package/templates/template-router/src/lib/single-flight.ts +9 -7
- package/templates/template-router/src/lib/storage-store.test.ts +63 -0
- package/templates/template-router/src/lib/storage-store.ts +22 -7
- package/templates/template-router/src/mocks/live.ts +110 -0
- package/templates/template-router/src/mocks/orders.ts +39 -0
- package/templates/template-router/src/mocks/people.ts +38 -0
- package/templates/template-router/src/mocks/server.ts +133 -5
- package/templates/template-router/src/providers/index.tsx +9 -15
- package/templates/template-router/src/providers/theme.test.tsx +3 -1
- package/templates/template-router/src/providers/theme.tsx +7 -2
- package/templates/template-router/src/routes/-not-found.tsx +1 -1
- package/templates/template-router/src/routes/-pending.tsx +1 -1
- package/templates/template-router/src/routes/_authenticated/index.tsx +21 -4
- package/templates/template-router/src/routes/_authenticated/notes/$noteId.tsx +5 -5
- package/templates/template-router/src/routes/_authenticated/notes/index.tsx +1 -1
- package/templates/template-router/src/routes/_authenticated/orders/$orderId.tsx +62 -0
- package/templates/template-router/src/routes/_authenticated/orders/index.tsx +189 -0
- package/templates/template-router/src/routes/_authenticated.tsx +7 -42
- package/templates/template-router/src/routes/sign-in.tsx +2 -2
- package/templates/template-router/tsconfig.app.json +1 -1
- package/templates/template-router/tsconfig.node.json +3 -3
- package/templates/template-router/vite.config.ts +2 -15
- package/templates/template-router/vitest.config.ts +16 -0
- package/templates/template-start/.claude/settings.json +1 -1
- package/templates/template-start/.claude/skills/project-conventions/SKILL.md +40 -30
- package/templates/template-start/.oxfmtrc.json +1 -0
- package/templates/template-start/.oxlintrc.json +18 -8
- package/templates/template-start/CLAUDE.md +3 -5
- package/templates/template-start/_gitignore +2 -0
- package/templates/template-start/doctor.config.json +4 -0
- package/templates/template-start/e2e/dashboard.spec.ts +38 -0
- package/templates/template-start/e2e/keyboard.spec.ts +18 -8
- package/templates/template-start/e2e/orders.spec.ts +82 -0
- package/templates/template-start/e2e/utils.ts +1 -1
- package/templates/template-start/package.json +10 -6
- package/templates/template-start/playwright.config.ts +15 -23
- package/templates/template-start/src/api/auth/session-store.test.ts +14 -4
- package/templates/template-start/src/api/index.ts +59 -28
- package/templates/template-start/src/api/live/feed.ts +14 -0
- package/templates/template-start/src/api/live/index.ts +105 -0
- package/templates/template-start/src/api/live/live.test.ts +67 -0
- package/templates/template-start/src/api/live/schema.ts +33 -0
- package/templates/template-start/src/api/orders/index.ts +63 -0
- package/templates/template-start/src/api/orders/input.ts +48 -0
- package/templates/template-start/src/api/orders/orders.test.ts +48 -0
- package/templates/template-start/src/api/orders/schema.ts +20 -0
- package/templates/template-start/src/api/refresh.test.ts +62 -0
- package/templates/template-start/src/components/app-shell.plain.tsx +29 -0
- package/templates/template-start/src/components/app-shell.tsx +24 -0
- package/templates/template-start/src/components/app-sidebar.tsx +74 -0
- package/templates/template-start/src/components/dashboard/activity-feed.tsx +42 -0
- package/templates/template-start/src/components/dashboard/dashboard.tsx +88 -0
- package/templates/template-start/src/components/dashboard/live-badge.tsx +23 -0
- package/templates/template-start/src/components/dashboard/revenue-chart.tsx +82 -0
- package/templates/template-start/src/components/dashboard/stat-tile.tsx +33 -0
- package/templates/template-start/src/components/error-screen.tsx +2 -2
- package/templates/template-start/src/components/header-actions.tsx +28 -0
- package/templates/template-start/src/components/nav.ts +17 -0
- package/templates/template-start/src/components/orders/create-order-dialog.tsx +114 -0
- package/templates/template-start/src/components/orders/order-status-badge.tsx +28 -0
- package/templates/template-start/src/components/orders/order-status-select.tsx +59 -0
- package/templates/template-start/src/components/orders/order-status.ts +11 -0
- package/templates/template-start/src/components/orders/orders-table.tsx +223 -0
- package/templates/template-start/src/components/root-error-boundary.tsx +3 -7
- package/templates/template-start/src/components/ui/badge.tsx +48 -0
- package/templates/template-start/src/components/ui/button.tsx +2 -2
- package/templates/template-start/src/components/ui/card.tsx +87 -0
- package/templates/template-start/src/components/ui/chart.tsx +338 -0
- package/templates/template-start/src/components/ui/dialog.tsx +136 -0
- package/templates/template-start/src/components/ui/dropdown-menu.tsx +255 -0
- package/templates/template-start/src/components/ui/empty.tsx +2 -2
- package/templates/template-start/src/components/ui/field.tsx +6 -6
- package/templates/template-start/src/components/ui/input.tsx +1 -1
- package/templates/template-start/src/components/ui/select.tsx +189 -0
- package/templates/template-start/src/components/ui/separator.tsx +1 -1
- package/templates/template-start/src/components/ui/sheet.tsx +125 -0
- package/templates/template-start/src/components/ui/sidebar.tsx +682 -0
- package/templates/template-start/src/components/ui/skeleton.tsx +13 -0
- package/templates/template-start/src/components/ui/table.tsx +86 -0
- package/templates/template-start/src/components/ui/textarea.tsx +1 -1
- package/templates/template-start/src/components/ui/toast.tsx +3 -3
- package/templates/template-start/src/components/ui/tooltip.tsx +2 -2
- package/templates/template-start/src/hooks/use-mobile.ts +18 -0
- package/templates/template-start/src/index.css +0 -7
- package/templates/template-start/src/lib/format.ts +39 -0
- package/templates/template-start/src/lib/query-client.test.tsx +21 -10
- package/templates/template-start/src/lib/query-client.ts +26 -15
- package/templates/template-start/src/lib/sidebar-store.ts +10 -0
- package/templates/template-start/src/lib/single-flight.test.ts +14 -3
- package/templates/template-start/src/lib/single-flight.ts +9 -7
- package/templates/template-start/src/lib/storage-store.test.ts +63 -0
- package/templates/template-start/src/lib/storage-store.ts +27 -7
- package/templates/template-start/src/mocks/live.ts +110 -0
- package/templates/template-start/src/mocks/orders.ts +39 -0
- package/templates/template-start/src/mocks/people.ts +38 -0
- package/templates/template-start/src/mocks/server.ts +133 -5
- package/templates/template-start/src/providers/index.tsx +9 -15
- package/templates/template-start/src/providers/theme.test.tsx +3 -1
- package/templates/template-start/src/providers/theme.tsx +7 -2
- package/templates/template-start/src/routes/-not-found.tsx +1 -1
- package/templates/template-start/src/routes/-pending.tsx +1 -1
- package/templates/template-start/src/routes/__root.tsx +1 -0
- package/templates/template-start/src/routes/_authenticated/index.tsx +21 -4
- package/templates/template-start/src/routes/_authenticated/notes/$noteId.tsx +5 -5
- package/templates/template-start/src/routes/_authenticated/notes/index.tsx +1 -1
- package/templates/template-start/src/routes/_authenticated/orders/$orderId.tsx +62 -0
- package/templates/template-start/src/routes/_authenticated/orders/index.tsx +189 -0
- package/templates/template-start/src/routes/_authenticated.tsx +7 -42
- package/templates/template-start/src/routes/sign-in.tsx +2 -2
- package/templates/template-start/tsconfig.app.json +1 -1
- package/templates/template-start/tsconfig.node.json +3 -3
- package/templates/template-start/tsr.config.json +7 -0
- package/templates/template-start/vite.config.ts +2 -12
- package/templates/template-start/vitest.config.ts +16 -0
- package/backend-overlay/web/src/routeTree.gen.ts +0 -120
- package/backend-overlay/web-start/src/routeTree.gen.ts +0 -191
- package/templates/template-router/e2e/global-setup.ts +0 -24
- package/templates/template-router/src/routeTree.gen.ts +0 -145
- package/templates/template-start/e2e/global-setup.ts +0 -24
- package/templates/template-start/src/routeTree.gen.ts +0 -217
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
[
|
|
2
2
|
"src/mocks",
|
|
3
3
|
"src/api/notes/schema.ts",
|
|
4
|
+
"src/api/orders/schema.ts",
|
|
5
|
+
"src/api/orders/orders.test.ts",
|
|
6
|
+
"src/api/live/schema.ts",
|
|
7
|
+
"src/api/live/feed.ts",
|
|
8
|
+
"src/api/live/live.test.ts",
|
|
4
9
|
"src/api/notes/notes.test.ts",
|
|
5
10
|
"src/routes/_authenticated/notes",
|
|
6
11
|
"src/api/auth/schema.ts",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { treaty } from '@elysia/eden'
|
|
1
|
+
import { EdenFetchError, treaty } from '@elysia/eden'
|
|
2
2
|
|
|
3
3
|
import type { App } from '@repo/api'
|
|
4
4
|
|
|
@@ -12,3 +12,13 @@ export const api = treaty<App>(baseUrl, {
|
|
|
12
12
|
fetch: { credentials: 'include' },
|
|
13
13
|
throwHttpError: true
|
|
14
14
|
}).api // the server's `/api` group
|
|
15
|
+
|
|
16
|
+
// route loaders turn this into `notFound()`; every variant of `api` exports the same helper
|
|
17
|
+
export const isNotFound = (error: unknown) =>
|
|
18
|
+
error instanceof EdenFetchError && error.status === 404
|
|
19
|
+
|
|
20
|
+
// `throwHttpError` already turns failures into exceptions, so a null body is a contract break
|
|
21
|
+
export const unwrap = <T>({ data }: { data: T | null }): T => {
|
|
22
|
+
if (data === null) throw new Error('Empty response')
|
|
23
|
+
return data
|
|
24
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { queryOptions, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { api, unwrap } from '@/api'
|
|
5
|
+
|
|
6
|
+
// types flow from the Elysia routes through Eden — no schema to keep in sync
|
|
7
|
+
export type LiveSnapshot = Awaited<ReturnType<typeof getLiveSnapshot>>
|
|
8
|
+
export type Metrics = LiveSnapshot['metrics'][number]
|
|
9
|
+
export type Activity = LiveSnapshot['activity'][number]
|
|
10
|
+
type LiveEvent = { type: 'metrics'; data: Metrics } | { type: 'activity'; data: Activity }
|
|
11
|
+
|
|
12
|
+
// the same bounds the server's ring buffer uses
|
|
13
|
+
const METRICS_WINDOW = 60
|
|
14
|
+
const ACTIVITY_LIMIT = 20
|
|
15
|
+
|
|
16
|
+
export const getLiveSnapshot = async () => unwrap(await api.live.snapshot.get())
|
|
17
|
+
|
|
18
|
+
// `Infinity`: the socket keeps this entry fresh; a refetch only happens after a reconnect
|
|
19
|
+
export const liveSnapshotQuery = queryOptions({
|
|
20
|
+
queryKey: ['live'],
|
|
21
|
+
queryFn: getLiveSnapshot,
|
|
22
|
+
staleTime: Infinity
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export type LiveStatus = 'connecting' | 'live' | 'reconnecting'
|
|
26
|
+
|
|
27
|
+
const applyLiveEvent = (snapshot: LiveSnapshot, event: LiveEvent): LiveSnapshot => {
|
|
28
|
+
switch (event.type) {
|
|
29
|
+
case 'metrics':
|
|
30
|
+
return { ...snapshot, metrics: [...snapshot.metrics.slice(1 - METRICS_WINDOW), event.data] }
|
|
31
|
+
case 'activity':
|
|
32
|
+
return { ...snapshot, activity: [event.data, ...snapshot.activity].slice(0, ACTIVITY_LIMIT) }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const BACKOFF_MS = [1_000, 2_000, 5_000, 10_000]
|
|
37
|
+
|
|
38
|
+
type ConnectOptions = {
|
|
39
|
+
onEvent: (event: LiveEvent) => void
|
|
40
|
+
onStatus: (status: LiveStatus, reconnected: boolean) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// one socket, reconnecting with backoff; the session cookie authorises the upgrade
|
|
44
|
+
export const connectLive = ({ onEvent, onStatus }: ConnectOptions) => {
|
|
45
|
+
let socket: ReturnType<typeof api.live.subscribe> | null = null
|
|
46
|
+
let attempt = 0
|
|
47
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
48
|
+
let closed = false
|
|
49
|
+
|
|
50
|
+
const open = () => {
|
|
51
|
+
socket = api.live.subscribe()
|
|
52
|
+
socket.on('open', () => {
|
|
53
|
+
onStatus('live', attempt > 0)
|
|
54
|
+
attempt = 0
|
|
55
|
+
})
|
|
56
|
+
socket.subscribe(({ data }) => onEvent(data as LiveEvent))
|
|
57
|
+
socket.on('close', () => {
|
|
58
|
+
if (closed) return
|
|
59
|
+
onStatus('reconnecting', false)
|
|
60
|
+
timer = setTimeout(open, BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)])
|
|
61
|
+
attempt += 1
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
onStatus('connecting', false)
|
|
66
|
+
open()
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
closed = true
|
|
70
|
+
clearTimeout(timer)
|
|
71
|
+
socket?.close()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const useLiveFeed = () => {
|
|
76
|
+
const queryClient = useQueryClient()
|
|
77
|
+
const { data } = useSuspenseQuery(liveSnapshotQuery)
|
|
78
|
+
const [status, setStatus] = useState<LiveStatus>('connecting')
|
|
79
|
+
|
|
80
|
+
useEffect(
|
|
81
|
+
() =>
|
|
82
|
+
connectLive({
|
|
83
|
+
onEvent: event =>
|
|
84
|
+
queryClient.setQueryData(liveSnapshotQuery.queryKey, snapshot =>
|
|
85
|
+
snapshot ? applyLiveEvent(snapshot, event) : snapshot
|
|
86
|
+
),
|
|
87
|
+
onStatus: (next, reconnected) => {
|
|
88
|
+
setStatus(next)
|
|
89
|
+
// frames missed while offline: the snapshot fills the gap
|
|
90
|
+
if (reconnected) void queryClient.invalidateQueries(liveSnapshotQuery)
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
[queryClient]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return { ...data, status }
|
|
97
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { queryOptions, useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
|
2
|
+
|
|
3
|
+
import { api, unwrap } from '@/api'
|
|
4
|
+
import type { CreateOrderInput, OrdersSearch, UpdateOrderStatusInput } from '@/api/orders/input'
|
|
5
|
+
|
|
6
|
+
// types flow from the Elysia routes through Eden — no schema to keep in sync
|
|
7
|
+
export type OrdersPage = Awaited<ReturnType<typeof getOrders>>
|
|
8
|
+
export type Order = OrdersPage['items'][number]
|
|
9
|
+
|
|
10
|
+
export const getOrders = async (search: OrdersSearch) =>
|
|
11
|
+
unwrap(await api.orders.get({ query: search }))
|
|
12
|
+
export const getOrder = async (id: string) => unwrap(await api.orders({ id }).get())
|
|
13
|
+
export const createOrder = async (input: CreateOrderInput) => unwrap(await api.orders.post(input))
|
|
14
|
+
export const updateOrderStatus = async ({
|
|
15
|
+
id,
|
|
16
|
+
...input
|
|
17
|
+
}: UpdateOrderStatusInput & { id: string }) => unwrap(await api.orders({ id }).patch(input))
|
|
18
|
+
|
|
19
|
+
// ['orders', 'list', …] and ['orders', 'detail', …]: one prefix invalidates both
|
|
20
|
+
const ordersKey = ['orders'] as const
|
|
21
|
+
const listKey = [...ordersKey, 'list'] as const
|
|
22
|
+
|
|
23
|
+
export const ordersQuery = (search: OrdersSearch) =>
|
|
24
|
+
queryOptions({ queryKey: [...listKey, search], queryFn: () => getOrders(search) })
|
|
25
|
+
export const orderQuery = (id: string) =>
|
|
26
|
+
queryOptions({ queryKey: [...ordersKey, 'detail', id], queryFn: () => getOrder(id) })
|
|
27
|
+
|
|
28
|
+
export const useOrders = (search: OrdersSearch) => useSuspenseQuery(ordersQuery(search))
|
|
29
|
+
export const useOrder = (id: string) => useSuspenseQuery(orderQuery(id))
|
|
30
|
+
|
|
31
|
+
export const useCreateOrder = () => {
|
|
32
|
+
const queryClient = useQueryClient()
|
|
33
|
+
return useMutation({
|
|
34
|
+
mutationFn: createOrder,
|
|
35
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: listKey })
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const patchStatus = (order: Order, id: string, status: Order['status']) =>
|
|
40
|
+
order.id === id ? { ...order, status } : order
|
|
41
|
+
|
|
42
|
+
// optimistic: the badge flips at once; a failure rolls every touched page and detail back
|
|
43
|
+
export const useUpdateOrderStatus = () => {
|
|
44
|
+
const queryClient = useQueryClient()
|
|
45
|
+
return useMutation({
|
|
46
|
+
mutationFn: updateOrderStatus,
|
|
47
|
+
onMutate: async ({ id, status }) => {
|
|
48
|
+
await queryClient.cancelQueries({ queryKey: ordersKey })
|
|
49
|
+
const lists = queryClient.getQueriesData<OrdersPage>({ queryKey: listKey })
|
|
50
|
+
const detail = queryClient.getQueryData(orderQuery(id).queryKey)
|
|
51
|
+
queryClient.setQueriesData<OrdersPage>({ queryKey: listKey }, page =>
|
|
52
|
+
page ? { ...page, items: page.items.map(order => patchStatus(order, id, status)) } : page
|
|
53
|
+
)
|
|
54
|
+
queryClient.setQueryData(orderQuery(id).queryKey, order =>
|
|
55
|
+
order ? patchStatus(order, id, status) : order
|
|
56
|
+
)
|
|
57
|
+
return { lists, detail }
|
|
58
|
+
},
|
|
59
|
+
onError: (_error, { id }, context) => {
|
|
60
|
+
for (const [key, page] of context?.lists ?? []) queryClient.setQueryData(key, page)
|
|
61
|
+
queryClient.setQueryData(orderQuery(id).queryKey, context?.detail)
|
|
62
|
+
},
|
|
63
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: ordersKey })
|
|
64
|
+
})
|
|
65
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useRouter } from '@tanstack/react-router'
|
|
2
|
+
import { LogOut } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { useSession, useSignOut } from '@/api/auth'
|
|
5
|
+
import { ThemeToggle } from '@/components/theme-toggle'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
|
|
8
|
+
export const HeaderActions = () => {
|
|
9
|
+
const { data: session } = useSession()
|
|
10
|
+
const signOut = useSignOut()
|
|
11
|
+
const router = useRouter()
|
|
12
|
+
|
|
13
|
+
const onSignOut = async () => {
|
|
14
|
+
await signOut.mutateAsync()
|
|
15
|
+
await router.navigate({ to: '/sign-in' })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className='ml-auto flex items-center gap-2 sm:gap-4'>
|
|
20
|
+
<span className='hidden truncate text-sm text-muted-foreground sm:inline'>
|
|
21
|
+
{session?.user.email}
|
|
22
|
+
</span>
|
|
23
|
+
<ThemeToggle />
|
|
24
|
+
<Button variant='outline' size='sm' onClick={onSignOut} disabled={signOut.isPending}>
|
|
25
|
+
<LogOut data-icon='inline-start' />
|
|
26
|
+
Sign out
|
|
27
|
+
</Button>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -60,6 +60,20 @@ it('always toasts a failed mutation', async () => {
|
|
|
60
60
|
expect(toasts()[0]).toHaveTextContent('nope')
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
+
it('stays silent for a mutation that shows its own errors', async () => {
|
|
64
|
+
await queryClient
|
|
65
|
+
.getMutationCache()
|
|
66
|
+
.build(queryClient, {
|
|
67
|
+
mutationFn: () => Promise.reject(new EdenFetchError(422, { message: 'Email is taken' })),
|
|
68
|
+
meta: { silentError: true }
|
|
69
|
+
})
|
|
70
|
+
.execute(undefined)
|
|
71
|
+
.catch(() => {})
|
|
72
|
+
await flushToasts()
|
|
73
|
+
|
|
74
|
+
expect(toasts()).toHaveLength(0)
|
|
75
|
+
})
|
|
76
|
+
|
|
63
77
|
it('does not retry a 4xx query', async () => {
|
|
64
78
|
const queryFn = vi.fn(() => Promise.reject(httpError(404)))
|
|
65
79
|
|
|
@@ -68,6 +82,14 @@ it('does not retry a 4xx query', async () => {
|
|
|
68
82
|
expect(queryFn).toHaveBeenCalledTimes(1)
|
|
69
83
|
})
|
|
70
84
|
|
|
85
|
+
it('retries a rate-limited query', async () => {
|
|
86
|
+
const queryFn = vi.fn(() => Promise.reject(httpError(429)))
|
|
87
|
+
|
|
88
|
+
await queryClient.query({ queryKey: ['e'], queryFn, retryDelay: 0 }).catch(() => {})
|
|
89
|
+
|
|
90
|
+
expect(queryFn).toHaveBeenCalledTimes(3)
|
|
91
|
+
})
|
|
92
|
+
|
|
71
93
|
it('retries a 5xx query up to the configured limit', async () => {
|
|
72
94
|
const queryFn = vi.fn(() => Promise.reject(httpError(503)))
|
|
73
95
|
|
|
@@ -3,16 +3,32 @@ import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'
|
|
|
3
3
|
|
|
4
4
|
import { toast } from '@/components/ui/toast'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const getErrorMessage = (error: unknown) => {
|
|
10
|
-
if (error instanceof EdenFetchError) {
|
|
11
|
-
return (
|
|
12
|
-
(error.value as { message?: string } | null)?.message ?? `Request failed (${error.status})`
|
|
13
|
-
)
|
|
6
|
+
declare module '@tanstack/react-query' {
|
|
7
|
+
interface Register {
|
|
8
|
+
mutationMeta: { silentError?: boolean }
|
|
14
9
|
}
|
|
15
|
-
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 408 and 429 are transient despite being 4xx
|
|
13
|
+
const RETRYABLE_CLIENT_ERRORS = new Set([408, 429])
|
|
14
|
+
|
|
15
|
+
const isFinalError = (error: unknown) => {
|
|
16
|
+
if (!(error instanceof EdenFetchError)) return false
|
|
17
|
+
const { status } = error
|
|
18
|
+
return status >= 400 && status < 500 && !RETRYABLE_CLIENT_ERRORS.has(status)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// the server's `{ message }` beats eden's bare status
|
|
22
|
+
const getErrorMessage = (error: unknown): string => {
|
|
23
|
+
if (!(error instanceof EdenFetchError)) {
|
|
24
|
+
return error instanceof Error && error.message.trim() ? error.message : 'Something went wrong'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const message = (error.value as { message?: string } | null)?.message
|
|
28
|
+
|
|
29
|
+
return typeof message === 'string' && message.trim()
|
|
30
|
+
? message
|
|
31
|
+
: `Request failed (${error.status})`
|
|
16
32
|
}
|
|
17
33
|
|
|
18
34
|
const notifyError = (error: unknown) => {
|
|
@@ -25,11 +41,15 @@ export const queryClient = new QueryClient({
|
|
|
25
41
|
if (query.state.data !== undefined) notifyError(error)
|
|
26
42
|
}
|
|
27
43
|
}),
|
|
28
|
-
mutationCache: new MutationCache({
|
|
44
|
+
mutationCache: new MutationCache({
|
|
45
|
+
onError: (error, _variables, _context, mutation) => {
|
|
46
|
+
if (!mutation.meta?.silentError) notifyError(error)
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
29
49
|
defaultOptions: {
|
|
30
50
|
queries: {
|
|
31
51
|
staleTime: 60_000,
|
|
32
|
-
retry: (failureCount, error) => !
|
|
52
|
+
retry: (failureCount, error) => !isFinalError(error) && failureCount < 2
|
|
33
53
|
},
|
|
34
54
|
mutations: {
|
|
35
55
|
retry: false
|
|
@@ -1,65 +1,30 @@
|
|
|
1
1
|
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
-
import { Trash2 } from 'lucide-react'
|
|
3
|
-
import { useState } from 'react'
|
|
4
2
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import {
|
|
3
|
+
import { useSession } from '@/api/auth'
|
|
4
|
+
// realtime:start
|
|
5
|
+
import { liveSnapshotQuery } from '@/api/live'
|
|
6
|
+
import { Dashboard } from '@/components/dashboard/dashboard'
|
|
7
|
+
// realtime:end
|
|
8
8
|
|
|
9
9
|
export const Route = createFileRoute('/_authenticated/')({
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// realtime:start
|
|
11
|
+
loader: ({ context }) => context.queryClient.query({ ...liveSnapshotQuery, staleTime: 'static' }),
|
|
12
|
+
// realtime:end
|
|
13
|
+
component: HomePage
|
|
12
14
|
})
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const notes = useNotes()
|
|
17
|
-
const createNote = useCreateNote()
|
|
18
|
-
const deleteNote = useDeleteNote()
|
|
19
|
-
const [title, setTitle] = useState('')
|
|
20
|
-
|
|
21
|
-
const onSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
|
22
|
-
e.preventDefault()
|
|
23
|
-
const trimmed = title.trim()
|
|
24
|
-
if (!trimmed) return
|
|
25
|
-
createNote.mutate(trimmed, { onSuccess: () => setTitle('') })
|
|
26
|
-
}
|
|
16
|
+
function HomePage() {
|
|
17
|
+
const { data: session } = useSession()
|
|
27
18
|
|
|
28
19
|
return (
|
|
29
|
-
<main className='
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
onChange={e => setTitle(e.target.value)}
|
|
38
|
-
maxLength={200}
|
|
39
|
-
/>
|
|
40
|
-
<Button type='submit' disabled={createNote.isPending}>
|
|
41
|
-
Add
|
|
42
|
-
</Button>
|
|
43
|
-
</form>
|
|
44
|
-
|
|
45
|
-
{notes.data?.length === 0 && <p className='text-muted-foreground text-sm'>No notes yet.</p>}
|
|
46
|
-
|
|
47
|
-
<ul className='divide-y rounded-lg border'>
|
|
48
|
-
{notes.data?.map(note => (
|
|
49
|
-
<li key={note.id} className='flex items-center justify-between px-3 py-2'>
|
|
50
|
-
<span className='text-sm'>{note.title}</span>
|
|
51
|
-
<Button
|
|
52
|
-
variant='ghost'
|
|
53
|
-
size='icon-sm'
|
|
54
|
-
aria-label={`Delete ${note.title}`}
|
|
55
|
-
disabled={deleteNote.isPending}
|
|
56
|
-
onClick={() => deleteNote.mutate(note.id)}
|
|
57
|
-
>
|
|
58
|
-
<Trash2 />
|
|
59
|
-
</Button>
|
|
60
|
-
</li>
|
|
61
|
-
))}
|
|
62
|
-
</ul>
|
|
20
|
+
<main className='flex flex-1 flex-col gap-6'>
|
|
21
|
+
<div className='flex flex-col gap-1'>
|
|
22
|
+
<h1 className='text-2xl font-semibold'>PLACEHOLDER</h1>
|
|
23
|
+
<p className='text-sm text-muted-foreground'>Signed in as {session?.user.email}</p>
|
|
24
|
+
</div>
|
|
25
|
+
{/* realtime:start */}
|
|
26
|
+
<Dashboard />
|
|
27
|
+
{/* realtime:end */}
|
|
63
28
|
</main>
|
|
64
29
|
)
|
|
65
30
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { Trash2 } from 'lucide-react'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { useCreateNote, useDeleteNote, useNotes, notesQuery } from '@/api/notes'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import { Input } from '@/components/ui/input'
|
|
8
|
+
|
|
9
|
+
export const Route = createFileRoute('/_authenticated/notes/')({
|
|
10
|
+
loader: ({ context }) => context.queryClient.query({ ...notesQuery, staleTime: 'static' }),
|
|
11
|
+
component: NotesPage
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
// demo of the Drizzle → Elysia → Eden → React Query path; delete with the notes modules
|
|
15
|
+
function NotesPage() {
|
|
16
|
+
const notes = useNotes()
|
|
17
|
+
const createNote = useCreateNote()
|
|
18
|
+
const deleteNote = useDeleteNote()
|
|
19
|
+
const [title, setTitle] = useState('')
|
|
20
|
+
|
|
21
|
+
const onSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
|
22
|
+
e.preventDefault()
|
|
23
|
+
const trimmed = title.trim()
|
|
24
|
+
if (!trimmed) return
|
|
25
|
+
createNote.mutate(trimmed, { onSuccess: () => setTitle('') })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<main className='mx-auto w-full max-w-md space-y-4'>
|
|
30
|
+
<h1 className='text-2xl font-semibold'>Notes</h1>
|
|
31
|
+
|
|
32
|
+
<form onSubmit={onSubmit} className='flex gap-2'>
|
|
33
|
+
<Input
|
|
34
|
+
aria-label='New note'
|
|
35
|
+
placeholder='New note…'
|
|
36
|
+
value={title}
|
|
37
|
+
onChange={e => setTitle(e.target.value)}
|
|
38
|
+
maxLength={200}
|
|
39
|
+
/>
|
|
40
|
+
<Button type='submit' disabled={createNote.isPending}>
|
|
41
|
+
Add
|
|
42
|
+
</Button>
|
|
43
|
+
</form>
|
|
44
|
+
|
|
45
|
+
{notes.data?.length === 0 && <p className='text-sm text-muted-foreground'>No notes yet.</p>}
|
|
46
|
+
|
|
47
|
+
<ul className='divide-y rounded-lg border'>
|
|
48
|
+
{notes.data?.map(note => (
|
|
49
|
+
<li key={note.id} className='flex items-center justify-between px-3 py-2'>
|
|
50
|
+
<span className='text-sm'>{note.title}</span>
|
|
51
|
+
<Button
|
|
52
|
+
variant='ghost'
|
|
53
|
+
size='icon-sm'
|
|
54
|
+
aria-label={`Delete ${note.title}`}
|
|
55
|
+
disabled={deleteNote.isPending}
|
|
56
|
+
onClick={() => deleteNote.mutate(note.id)}
|
|
57
|
+
>
|
|
58
|
+
<Trash2 />
|
|
59
|
+
</Button>
|
|
60
|
+
</li>
|
|
61
|
+
))}
|
|
62
|
+
</ul>
|
|
63
|
+
</main>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -25,7 +25,7 @@ function AuthenticatedLayout() {
|
|
|
25
25
|
<>
|
|
26
26
|
<header className='flex h-14 items-center justify-between border-b px-6'>
|
|
27
27
|
<div className='flex items-center gap-4'>
|
|
28
|
-
<span className='text-muted-foreground
|
|
28
|
+
<span className='text-sm text-muted-foreground'>{session?.user.email}</span>
|
|
29
29
|
<ThemeToggle />
|
|
30
30
|
<Button variant='outline' size='sm' onClick={onSignOut} disabled={signOut.isPending}>
|
|
31
31
|
<LogOut data-icon='inline-start' />
|
|
@@ -74,7 +74,7 @@ function SignInPage() {
|
|
|
74
74
|
{formState.isSubmitting ? 'Signing in…' : 'Sign in'}
|
|
75
75
|
</Button>
|
|
76
76
|
|
|
77
|
-
<p className='text-
|
|
77
|
+
<p className='text-center text-sm text-muted-foreground'>
|
|
78
78
|
No account?{' '}
|
|
79
79
|
<Link to='/sign-up' search={{ redirect: redirectTo }} className='underline'>
|
|
80
80
|
Sign up
|
|
@@ -77,7 +77,7 @@ function SignUpPage() {
|
|
|
77
77
|
{formState.isSubmitting ? 'Creating…' : 'Sign up'}
|
|
78
78
|
</Button>
|
|
79
79
|
|
|
80
|
-
<p className='text-
|
|
80
|
+
<p className='text-center text-sm text-muted-foreground'>
|
|
81
81
|
Already have an account?{' '}
|
|
82
82
|
<Link to='/sign-in' search={{ redirect: redirectTo }} className='underline'>
|
|
83
83
|
Sign in
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Start override: the SSR runtime has no `window`, so the origin cannot be read there.
|
|
2
|
-
import { treaty } from '@elysia/eden'
|
|
2
|
+
import { EdenFetchError, treaty } from '@elysia/eden'
|
|
3
3
|
|
|
4
4
|
import type { App } from '@repo/api'
|
|
5
5
|
|
|
@@ -15,3 +15,13 @@ export const api = treaty<App>(baseUrl, {
|
|
|
15
15
|
fetch: { credentials: 'include' },
|
|
16
16
|
throwHttpError: true
|
|
17
17
|
}).api // the server's `/api` group
|
|
18
|
+
|
|
19
|
+
// route loaders turn this into `notFound()`; every variant of `api` exports the same helper
|
|
20
|
+
export const isNotFound = (error: unknown) =>
|
|
21
|
+
error instanceof EdenFetchError && error.status === 404
|
|
22
|
+
|
|
23
|
+
// `throwHttpError` already turns failures into exceptions, so a null body is a contract break
|
|
24
|
+
export const unwrap = <T>({ data }: { data: T | null }): T => {
|
|
25
|
+
if (data === null) throw new Error('Empty response')
|
|
26
|
+
return data
|
|
27
|
+
}
|
|
@@ -78,7 +78,7 @@ function SignInPage() {
|
|
|
78
78
|
{formState.isSubmitting ? 'Signing in…' : 'Sign in'}
|
|
79
79
|
</Button>
|
|
80
80
|
|
|
81
|
-
<p className='text-
|
|
81
|
+
<p className='text-center text-sm text-muted-foreground'>
|
|
82
82
|
No account?{' '}
|
|
83
83
|
<Link to='/sign-up' search={{ redirect: redirectTo }} className='underline'>
|
|
84
84
|
Sign up
|
|
@@ -81,7 +81,7 @@ function SignUpPage() {
|
|
|
81
81
|
{formState.isSubmitting ? 'Creating…' : 'Sign up'}
|
|
82
82
|
</Button>
|
|
83
83
|
|
|
84
|
-
<p className='text-
|
|
84
|
+
<p className='text-center text-sm text-muted-foreground'>
|
|
85
85
|
Already have an account?{' '}
|
|
86
86
|
<Link to='/sign-in' search={{ redirect: redirectTo }} className='underline'>
|
|
87
87
|
Sign in
|
package/features.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sidebar": {
|
|
3
|
+
"on": {
|
|
4
|
+
"remove": ["src/components/app-shell.plain.tsx"]
|
|
5
|
+
},
|
|
6
|
+
"off": {
|
|
7
|
+
"remove": [
|
|
8
|
+
"src/components/app-sidebar.tsx",
|
|
9
|
+
"src/components/app-shell.tsx",
|
|
10
|
+
"src/components/ui/sidebar.tsx",
|
|
11
|
+
"src/components/ui/sheet.tsx",
|
|
12
|
+
"src/hooks",
|
|
13
|
+
"src/lib/sidebar-store.ts"
|
|
14
|
+
],
|
|
15
|
+
"rename": {
|
|
16
|
+
"src/components/app-shell.plain.tsx": "src/components/app-shell.tsx"
|
|
17
|
+
},
|
|
18
|
+
"deps": []
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"table": {
|
|
22
|
+
"off": {
|
|
23
|
+
"remove": [
|
|
24
|
+
"src/api/orders",
|
|
25
|
+
"src/routes/_authenticated/orders",
|
|
26
|
+
"src/components/orders",
|
|
27
|
+
"src/mocks/orders.ts",
|
|
28
|
+
"src/components/ui/table.tsx",
|
|
29
|
+
"src/components/ui/dialog.tsx",
|
|
30
|
+
"src/components/ui/dropdown-menu.tsx",
|
|
31
|
+
"src/components/ui/select.tsx",
|
|
32
|
+
"e2e/orders.spec.ts"
|
|
33
|
+
],
|
|
34
|
+
"deps": ["@tanstack/react-table"],
|
|
35
|
+
"apiRemove": ["src/modules/orders"]
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"realtime": {
|
|
39
|
+
"off": {
|
|
40
|
+
"remove": [
|
|
41
|
+
"src/api/live",
|
|
42
|
+
"src/components/dashboard",
|
|
43
|
+
"src/mocks/live.ts",
|
|
44
|
+
"src/components/ui/chart.tsx",
|
|
45
|
+
"e2e/dashboard.spec.ts"
|
|
46
|
+
],
|
|
47
|
+
"deps": ["recharts"],
|
|
48
|
+
"apiRemove": ["src/modules/live"]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|