@rashidee/co2 1.2.5

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 (190) hide show
  1. package/dist/index.js +25449 -0
  2. package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
  3. package/drizzle/0001_wise_wolf_cub.sql +13 -0
  4. package/drizzle/0002_secret_spyke.sql +16 -0
  5. package/drizzle/0003_workspace_config_singleton.sql +16 -0
  6. package/drizzle/0004_terminal_claim_v120.sql +11 -0
  7. package/drizzle/0005_brainstorm_v124.sql +10 -0
  8. package/drizzle/0006_project_v140.sql +6 -0
  9. package/drizzle/0007_prd_wizard_v124.sql +14 -0
  10. package/drizzle/0008_workflow_artifact_v126.sql +24 -0
  11. package/drizzle/0009_project_import_v150.sql +7 -0
  12. package/drizzle/0010_quality_v151.sql +3 -0
  13. package/drizzle/meta/0000_snapshot.json +1335 -0
  14. package/drizzle/meta/0001_snapshot.json +1416 -0
  15. package/drizzle/meta/0002_snapshot.json +1510 -0
  16. package/drizzle/meta/_journal.json +83 -0
  17. package/package.json +41 -0
  18. package/plugin/.claude-plugin/marketplace.json +36 -0
  19. package/plugin/.claude-plugin/plugin.json +22 -0
  20. package/plugin/README.md +625 -0
  21. package/plugin/SKILLS.md +350 -0
  22. package/plugin/co2.svg +5 -0
  23. package/plugin/commands/brainstorm-loop.md +129 -0
  24. package/plugin/skills/conductor-defect/SKILL.md +898 -0
  25. package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
  26. package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
  27. package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
  28. package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
  29. package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
  30. package/plugin/skills/depgen-k8s/SKILL.md +468 -0
  31. package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
  32. package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
  33. package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
  34. package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
  35. package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
  36. package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
  37. package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
  38. package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
  39. package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
  40. package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
  41. package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
  42. package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
  43. package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
  44. package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
  45. package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
  46. package/plugin/skills/modelgen-relational/SKILL.md +707 -0
  47. package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
  48. package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
  49. package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
  50. package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
  51. package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
  52. package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
  53. package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
  54. package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
  55. package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
  56. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
  57. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
  58. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
  59. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
  60. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
  61. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
  62. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
  63. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
  64. package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
  65. package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
  66. package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
  67. package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
  68. package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
  69. package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
  70. package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
  71. package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
  72. package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
  73. package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
  74. package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
  75. package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
  76. package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
  77. package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
  78. package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
  79. package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
  80. package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
  81. package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
  82. package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
  83. package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
  84. package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
  85. package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
  86. package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
  87. package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
  88. package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
  89. package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
  90. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
  91. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
  92. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
  93. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
  94. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
  95. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
  96. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
  97. package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
  98. package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
  99. package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
  100. package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
  101. package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
  102. package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
  103. package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
  104. package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
  105. package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
  106. package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
  107. package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
  108. package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
  109. package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
  110. package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
  111. package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
  112. package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
  113. package/plugin/skills/testgen-functional/SKILL.md +1381 -0
  114. package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
  115. package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
  116. package/plugin/skills/util-projectinit/SKILL.md +307 -0
  117. package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
  118. package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
  119. package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
  120. package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
  121. package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
  122. package/plugin/skills/util-projectsync/SKILL.md +501 -0
  123. package/plugin/skills/util-updprd/SKILL.md +324 -0
  124. package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
  125. package/plugin/skills/util-ustagger/SKILL.md +154 -0
  126. package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
  127. package/static/assets/arc-DRKw7sjs.js +1 -0
  128. package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
  129. package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
  130. package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
  131. package/static/assets/channel-C2nVbuwv.js +1 -0
  132. package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
  133. package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
  134. package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
  135. package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
  136. package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
  137. package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
  138. package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
  139. package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
  140. package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
  141. package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
  142. package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
  143. package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
  144. package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
  145. package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
  146. package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
  147. package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
  148. package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
  149. package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
  150. package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
  151. package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
  152. package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
  153. package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
  154. package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
  155. package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
  156. package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
  157. package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
  158. package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
  159. package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
  160. package/static/assets/graph-C9eacEi8.js +1 -0
  161. package/static/assets/index-LZSQkLE2.css +41 -0
  162. package/static/assets/index-y7XDSgMG.js +495 -0
  163. package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
  164. package/static/assets/init-Gi6I4Gst.js +1 -0
  165. package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
  166. package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
  167. package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
  168. package/static/assets/katex-C5jXJg4s.js +257 -0
  169. package/static/assets/layout-DEXfKzaS.js +1 -0
  170. package/static/assets/linear-CLO--ryk.js +1 -0
  171. package/static/assets/map-Czzmt4hB.js +1 -0
  172. package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
  173. package/static/assets/ordinal-Cboi1Yqb.js +1 -0
  174. package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
  175. package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
  176. package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
  177. package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
  178. package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
  179. package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
  180. package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
  181. package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
  182. package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
  183. package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
  184. package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
  185. package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
  186. package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
  187. package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
  188. package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
  189. package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
  190. package/static/index.html +24 -0
@@ -0,0 +1,1212 @@
1
+ # Component Patterns — Tailwind Design Tokens, Headless UI, TanStack Query, Zustand, React Hook Form
2
+
3
+ This reference describes the reusable component and state patterns for the spec.
4
+ Include this content in Sections 5, 9, 10, 12, 14, 16, and 17 of the generated specification.
5
+
6
+ All UI is built **utility-first with Tailwind**; interactive/accessible widgets use
7
+ **Headless UI** primitives and **Heroicons**. Every composed/conditional className goes
8
+ through the `cn()` helper, and components with visual variants use `class-variance-authority`.
9
+
10
+ ---
11
+
12
+ ## Design Tokens & `cn()`
13
+
14
+ The Tailwind theme is driven by CSS custom properties (semantic tokens) so light/dark mode
15
+ switches automatically. See `spec-template.md` Section 5 for `tailwind.config.js`,
16
+ `src/index.css`, and the `cn()` helper. The token → class mapping the spec must produce:
17
+
18
+ | Mockup CSS property | Tailwind token / class |
19
+ |---|---|
20
+ | Primary brand color (e.g., `#2271b1`) | `--color-primary` → `bg-primary`, `text-primary`, `ring-primary` |
21
+ | Primary hover (e.g., `#135e96`) | `--color-primary-dark` → `hover:bg-primary-dark` |
22
+ | Page background (e.g., `#f0f0f1`) | `--color-canvas` → `bg-canvas` |
23
+ | Surface/card background (e.g., `#ffffff`) | `--color-surface` → `bg-surface` |
24
+ | Error color (e.g., `#d63638`) | `--color-danger` → `bg-danger`, `text-danger` |
25
+ | Font family | `theme.fontFamily.sans` → `font-sans` |
26
+ | Border radius | `theme.borderRadius.DEFAULT` → `rounded` |
27
+
28
+ ```typescript
29
+ // src/lib/utils/cn.ts
30
+ import { clsx, type ClassValue } from 'clsx';
31
+ import { twMerge } from 'tailwind-merge';
32
+
33
+ /** Merge Tailwind class names, resolving conflicts (last-wins) via tailwind-merge. */
34
+ export function cn(...inputs: ClassValue[]): string {
35
+ return twMerge(clsx(inputs));
36
+ }
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Button (cva variants)
42
+
43
+ ```tsx
44
+ // src/shared/components/Button.tsx
45
+ import { forwardRef, type ButtonHTMLAttributes } from 'react';
46
+ import { cva, type VariantProps } from 'class-variance-authority';
47
+ import { cn } from '../../lib/utils/cn';
48
+ import { Spinner } from './Spinner';
49
+
50
+ const buttonVariants = cva(
51
+ // base classes
52
+ 'inline-flex items-center justify-center gap-2 rounded font-semibold transition-colors ' +
53
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 ' +
54
+ 'disabled:pointer-events-none disabled:opacity-50',
55
+ {
56
+ variants: {
57
+ variant: {
58
+ primary: 'bg-primary text-white hover:bg-primary-dark',
59
+ outline:
60
+ 'border border-slate-300 bg-transparent text-slate-700 hover:bg-slate-100 ' +
61
+ 'dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800',
62
+ ghost: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800',
63
+ danger: 'bg-danger text-white hover:opacity-90',
64
+ },
65
+ size: {
66
+ sm: 'h-8 px-3 text-sm',
67
+ md: 'h-10 px-4 text-sm',
68
+ lg: 'h-11 px-6 text-base',
69
+ icon: 'h-9 w-9',
70
+ },
71
+ },
72
+ defaultVariants: { variant: 'primary', size: 'md' },
73
+ },
74
+ );
75
+
76
+ export interface ButtonProps
77
+ extends ButtonHTMLAttributes<HTMLButtonElement>,
78
+ VariantProps<typeof buttonVariants> {
79
+ /** Show a spinner and disable the button while an async action is in flight */
80
+ loading?: boolean;
81
+ }
82
+
83
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
84
+ ({ className, variant, size, loading, disabled, children, ...props }, ref) => (
85
+ <button
86
+ ref={ref}
87
+ className={cn(buttonVariants({ variant, size }), className)}
88
+ disabled={disabled || loading}
89
+ {...props}
90
+ >
91
+ {loading && <Spinner className="h-4 w-4" />}
92
+ {children}
93
+ </button>
94
+ ),
95
+ );
96
+ Button.displayName = 'Button';
97
+ ```
98
+
99
+ ```tsx
100
+ // src/shared/components/Spinner.tsx
101
+ import { cn } from '../../lib/utils/cn';
102
+
103
+ export function Spinner({ className }: { className?: string }) {
104
+ return (
105
+ <svg
106
+ className={cn('animate-spin text-current', className)}
107
+ viewBox="0 0 24 24"
108
+ fill="none"
109
+ aria-hidden="true"
110
+ >
111
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
112
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
113
+ </svg>
114
+ );
115
+ }
116
+ ```
117
+
118
+ ```tsx
119
+ // src/shared/components/Card.tsx
120
+ import { type HTMLAttributes } from 'react';
121
+ import { cn } from '../../lib/utils/cn';
122
+
123
+ export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
124
+ return (
125
+ <div
126
+ className={cn(
127
+ 'rounded border border-slate-200 bg-surface shadow-sm dark:border-slate-700',
128
+ className,
129
+ )}
130
+ {...props}
131
+ />
132
+ );
133
+ }
134
+ ```
135
+
136
+ ---
137
+
138
+ ## TanStack Query Setup
139
+
140
+ ```typescript
141
+ // src/lib/queryClient.ts
142
+ import { QueryClient } from '@tanstack/react-query';
143
+ import { useNotificationStore } from '../store/notification.store';
144
+
145
+ export const queryClient = new QueryClient({
146
+ defaultOptions: {
147
+ queries: {
148
+ // Data is considered fresh for 30 seconds — prevents redundant refetches
149
+ staleTime: 30_000,
150
+ // Unused data is garbage collected after 5 minutes
151
+ gcTime: 5 * 60_000,
152
+ // Retry failed queries up to 2 times (not for 4xx errors)
153
+ retry: (failureCount, error) => {
154
+ if (error instanceof Error && 'status' in error) {
155
+ const status = (error as { status: number }).status;
156
+ if (status >= 400 && status < 500) return false;
157
+ }
158
+ return failureCount < 2;
159
+ },
160
+ refetchOnWindowFocus: true,
161
+ },
162
+ mutations: {
163
+ onError: (error) => {
164
+ const message =
165
+ error instanceof Error ? error.message : 'An unexpected error occurred';
166
+ useNotificationStore.getState().push('error', message);
167
+ },
168
+ },
169
+ },
170
+ });
171
+ ```
172
+
173
+ ### Standard Query Hook Pattern
174
+
175
+ ```typescript
176
+ // Example: src/features/heroSection/hooks/useHeroSections.ts
177
+ import { useQuery } from '@tanstack/react-query';
178
+ import { heroSectionApi } from '../api/heroSectionApi';
179
+ import type { HeroSectionListParams } from '../heroSection.types';
180
+
181
+ export const heroSectionKeys = {
182
+ all: ['heroSections'] as const,
183
+ lists: () => [...heroSectionKeys.all, 'list'] as const,
184
+ list: (params: HeroSectionListParams) => [...heroSectionKeys.lists(), params] as const,
185
+ details: () => [...heroSectionKeys.all, 'detail'] as const,
186
+ detail: (id: string) => [...heroSectionKeys.details(), id] as const,
187
+ };
188
+
189
+ export function useHeroSections(params: HeroSectionListParams) {
190
+ return useQuery({
191
+ queryKey: heroSectionKeys.list(params),
192
+ queryFn: () => heroSectionApi.list(params),
193
+ placeholderData: (previousData) => previousData, // avoid empty flash on page change
194
+ });
195
+ }
196
+
197
+ export function useHeroSection(id: string) {
198
+ return useQuery({
199
+ queryKey: heroSectionKeys.detail(id),
200
+ queryFn: () => heroSectionApi.getById(id),
201
+ enabled: !!id,
202
+ });
203
+ }
204
+ ```
205
+
206
+ ### Standard Mutation Hook Pattern
207
+
208
+ ```typescript
209
+ // Example: src/features/heroSection/hooks/useHeroSectionMutations.ts
210
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
211
+ import { heroSectionApi } from '../api/heroSectionApi';
212
+ import { heroSectionKeys } from './useHeroSections';
213
+ import { useNotificationStore } from '../../../store/notification.store';
214
+ import type { HeroSectionCreate, HeroSectionUpdate } from '../heroSection.types';
215
+
216
+ export function useCreateHeroSection() {
217
+ const queryClient = useQueryClient();
218
+ const push = useNotificationStore((s) => s.push);
219
+
220
+ return useMutation({
221
+ mutationFn: (data: HeroSectionCreate) => heroSectionApi.create(data),
222
+ onSuccess: () => {
223
+ queryClient.invalidateQueries({ queryKey: heroSectionKeys.lists() });
224
+ push('success', 'Hero section created successfully.');
225
+ },
226
+ });
227
+ }
228
+
229
+ export function useUpdateHeroSection() {
230
+ const queryClient = useQueryClient();
231
+ const push = useNotificationStore((s) => s.push);
232
+
233
+ return useMutation({
234
+ mutationFn: ({ id, data }: { id: string; data: HeroSectionUpdate }) =>
235
+ heroSectionApi.update(id, data),
236
+ onSuccess: (_, { id }) => {
237
+ queryClient.invalidateQueries({ queryKey: heroSectionKeys.detail(id) });
238
+ queryClient.invalidateQueries({ queryKey: heroSectionKeys.lists() });
239
+ push('success', 'Hero section updated successfully.');
240
+ },
241
+ });
242
+ }
243
+
244
+ export function useDeleteHeroSection() {
245
+ const queryClient = useQueryClient();
246
+ const push = useNotificationStore((s) => s.push);
247
+
248
+ return useMutation({
249
+ mutationFn: (id: string) => heroSectionApi.delete(id),
250
+ onSuccess: () => {
251
+ queryClient.invalidateQueries({ queryKey: heroSectionKeys.lists() });
252
+ push('success', 'Hero section deleted.');
253
+ },
254
+ });
255
+ }
256
+ ```
257
+
258
+ ---
259
+
260
+ ## Zustand Stores
261
+
262
+ ### Notification Store
263
+
264
+ ```typescript
265
+ // src/store/notification.store.ts
266
+ import { create } from 'zustand';
267
+
268
+ type NotificationType = 'success' | 'error' | 'warning' | 'info';
269
+
270
+ export interface Notification {
271
+ id: string;
272
+ type: NotificationType;
273
+ message: string;
274
+ }
275
+
276
+ interface NotificationState {
277
+ notifications: Notification[];
278
+ push: (type: NotificationType, message: string) => void;
279
+ dismiss: (id: string) => void;
280
+ }
281
+
282
+ export const useNotificationStore = create<NotificationState>((set) => ({
283
+ notifications: [],
284
+
285
+ push: (type, message) => {
286
+ const id = crypto.randomUUID();
287
+ set((state) => ({ notifications: [...state.notifications, { id, type, message }] }));
288
+ // Auto-dismiss after 5 seconds
289
+ setTimeout(() => {
290
+ set((state) => ({
291
+ notifications: state.notifications.filter((n) => n.id !== id),
292
+ }));
293
+ }, 5_000);
294
+ },
295
+
296
+ dismiss: (id) =>
297
+ set((state) => ({ notifications: state.notifications.filter((n) => n.id !== id) })),
298
+ }));
299
+ ```
300
+
301
+ ### UI Store (sidebar + dark mode)
302
+
303
+ ```typescript
304
+ // src/store/ui.store.ts
305
+ import { create } from 'zustand';
306
+ import { persist } from 'zustand/middleware';
307
+
308
+ interface UiState {
309
+ sidebarOpen: boolean;
310
+ themeMode: 'light' | 'dark';
311
+ toggleSidebar: () => void;
312
+ toggleThemeMode: () => void;
313
+ }
314
+
315
+ export const useUiStore = create<UiState>()(
316
+ persist(
317
+ (set) => ({
318
+ sidebarOpen: true,
319
+ themeMode: 'light',
320
+ toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
321
+ toggleThemeMode: () =>
322
+ set((state) => ({ themeMode: state.themeMode === 'light' ? 'dark' : 'light' })),
323
+ }),
324
+ { name: 'ui-preferences' }, // Persisted to localStorage (safe — no tokens here)
325
+ ),
326
+ );
327
+ ```
328
+
329
+ > The `themeMode` value drives the Tailwind `dark` class on `document.documentElement`. The
330
+ > sync effect lives in `App.tsx` (see `spec-template.md` Section 5.5).
331
+
332
+ ---
333
+
334
+ ## React Hook Form + Zod Patterns
335
+
336
+ ### Zod Schema Example
337
+
338
+ ```typescript
339
+ // Example: src/features/heroSection/heroSection.schema.ts
340
+ import { z } from 'zod';
341
+
342
+ /**
343
+ * Validation schema for hero section create/update form.
344
+ * Rules derived from PRD.md NFRs and constraints.
345
+ */
346
+ export const heroSectionSchema = z
347
+ .object({
348
+ headline: z
349
+ .string()
350
+ .min(1, 'Headline is required')
351
+ .max(100, 'Headline must be at most 100 characters'),
352
+ subheadline: z
353
+ .string()
354
+ .min(1, 'Subheadline is required')
355
+ .max(200, 'Subheadline must be at most 200 characters'),
356
+ ctaText: z
357
+ .string()
358
+ .min(1, 'CTA text is required')
359
+ .max(50, 'CTA text must be at most 50 characters'),
360
+ ctaUrl: z.string().url('Must be a valid URL').min(1, 'CTA URL is required'),
361
+ effectiveDate: z.string().min(1, 'Effective date is required'),
362
+ expirationDate: z.string().min(1, 'Expiration date is required'),
363
+ // image is optional in schema — uploaded separately via multipart
364
+ })
365
+ .refine((data) => new Date(data.expirationDate) > new Date(data.effectiveDate), {
366
+ message: 'Expiration date must be after effective date',
367
+ path: ['expirationDate'],
368
+ });
369
+
370
+ export type HeroSectionFormValues = z.infer<typeof heroSectionSchema>;
371
+ ```
372
+
373
+ ### Form Component Pattern
374
+
375
+ ```tsx
376
+ // Example: src/features/heroSection/components/HeroSectionForm.tsx
377
+ import { useState } from 'react';
378
+ import { useForm } from 'react-hook-form';
379
+ import { zodResolver } from '@hookform/resolvers/zod';
380
+ import { Button } from '../../../shared/components/Button';
381
+ import { TextFieldController } from '../../../shared/form/TextFieldController';
382
+ import { TextAreaController } from '../../../shared/form/TextAreaController';
383
+ import { DatePickerController } from '../../../shared/form/DatePickerController';
384
+ import { ImageUpload } from '../../../shared/components/ImageUpload';
385
+ import { heroSectionSchema, type HeroSectionFormValues } from '../heroSection.schema';
386
+
387
+ interface HeroSectionFormProps {
388
+ defaultValues?: Partial<HeroSectionFormValues>;
389
+ onSubmit: (values: HeroSectionFormValues, imageFile?: File) => void;
390
+ isSubmitting: boolean;
391
+ onCancel: () => void;
392
+ }
393
+
394
+ export function HeroSectionForm({
395
+ defaultValues,
396
+ onSubmit,
397
+ isSubmitting,
398
+ onCancel,
399
+ }: HeroSectionFormProps) {
400
+ const { control, handleSubmit } = useForm<HeroSectionFormValues>({
401
+ resolver: zodResolver(heroSectionSchema),
402
+ defaultValues: {
403
+ headline: '',
404
+ subheadline: '',
405
+ ctaText: '',
406
+ ctaUrl: '',
407
+ effectiveDate: '',
408
+ expirationDate: '',
409
+ ...defaultValues,
410
+ },
411
+ });
412
+
413
+ const [imageFile, setImageFile] = useState<File | undefined>();
414
+
415
+ const handleFormSubmit = (values: HeroSectionFormValues) => {
416
+ onSubmit(values, imageFile);
417
+ };
418
+
419
+ return (
420
+ <form onSubmit={handleSubmit(handleFormSubmit)} noValidate className="space-y-6">
421
+ <ImageUpload
422
+ label="Hero Image (1600x500 px)"
423
+ onFileChange={setImageFile}
424
+ aspectRatio="1600/500"
425
+ helperText="Image must be exactly 1600×500 pixels"
426
+ />
427
+ <TextFieldController
428
+ name="headline"
429
+ control={control}
430
+ label="Headline"
431
+ required
432
+ maxLength={100}
433
+ helperText="Max 100 characters"
434
+ />
435
+ <TextAreaController
436
+ name="subheadline"
437
+ control={control}
438
+ label="Subheadline"
439
+ required
440
+ rows={2}
441
+ maxLength={200}
442
+ />
443
+ <TextFieldController name="ctaText" control={control} label="CTA Button Text" required maxLength={50} />
444
+ <TextFieldController
445
+ name="ctaUrl"
446
+ control={control}
447
+ label="CTA Button URL"
448
+ required
449
+ placeholder="https://..."
450
+ />
451
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
452
+ <DatePickerController name="effectiveDate" control={control} label="Effective Date" required />
453
+ <DatePickerController name="expirationDate" control={control} label="Expiration Date" required />
454
+ </div>
455
+
456
+ <div className="flex justify-end gap-3">
457
+ <Button type="button" variant="outline" onClick={onCancel}>
458
+ Cancel
459
+ </Button>
460
+ <Button type="submit" loading={isSubmitting}>
461
+ {defaultValues ? 'Update' : 'Create'}
462
+ </Button>
463
+ </div>
464
+ </form>
465
+ );
466
+ }
467
+ ```
468
+
469
+ ### RHF Controller Wrappers
470
+
471
+ ```tsx
472
+ // src/shared/form/TextFieldController.tsx
473
+ import { Controller, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
474
+ import { cn } from '../../lib/utils/cn';
475
+
476
+ interface TextFieldControllerProps<T extends FieldValues> {
477
+ name: FieldPath<T>;
478
+ control: Control<T>;
479
+ label: string;
480
+ type?: 'text' | 'email' | 'number' | 'url' | 'password';
481
+ placeholder?: string;
482
+ required?: boolean;
483
+ maxLength?: number;
484
+ helperText?: string;
485
+ }
486
+
487
+ export function TextFieldController<T extends FieldValues>({
488
+ name,
489
+ control,
490
+ label,
491
+ type = 'text',
492
+ placeholder,
493
+ required,
494
+ maxLength,
495
+ helperText,
496
+ }: TextFieldControllerProps<T>) {
497
+ return (
498
+ <Controller
499
+ name={name}
500
+ control={control}
501
+ render={({ field, fieldState }) => (
502
+ <div className="space-y-1">
503
+ <label htmlFor={name} className="block text-sm font-medium text-slate-700 dark:text-slate-200">
504
+ {label}
505
+ {required && <span className="ml-0.5 text-danger">*</span>}
506
+ </label>
507
+ <input
508
+ {...field}
509
+ id={name}
510
+ type={type}
511
+ placeholder={placeholder}
512
+ maxLength={maxLength}
513
+ aria-invalid={!!fieldState.error}
514
+ className={cn(
515
+ 'block w-full rounded border bg-surface px-3 py-2 text-sm text-slate-900 shadow-sm',
516
+ 'focus:border-primary focus:ring-1 focus:ring-primary dark:text-slate-100',
517
+ fieldState.error ? 'border-danger' : 'border-slate-300 dark:border-slate-600',
518
+ )}
519
+ />
520
+ {(fieldState.error?.message ?? helperText) && (
521
+ <p className={cn('text-xs', fieldState.error ? 'text-danger' : 'text-slate-500')}>
522
+ {fieldState.error?.message ?? helperText}
523
+ </p>
524
+ )}
525
+ </div>
526
+ )}
527
+ />
528
+ );
529
+ }
530
+ ```
531
+
532
+ ```tsx
533
+ // src/shared/form/TextAreaController.tsx
534
+ import { Controller, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
535
+ import { cn } from '../../lib/utils/cn';
536
+
537
+ interface TextAreaControllerProps<T extends FieldValues> {
538
+ name: FieldPath<T>;
539
+ control: Control<T>;
540
+ label: string;
541
+ rows?: number;
542
+ required?: boolean;
543
+ maxLength?: number;
544
+ helperText?: string;
545
+ }
546
+
547
+ export function TextAreaController<T extends FieldValues>({
548
+ name,
549
+ control,
550
+ label,
551
+ rows = 3,
552
+ required,
553
+ maxLength,
554
+ helperText,
555
+ }: TextAreaControllerProps<T>) {
556
+ return (
557
+ <Controller
558
+ name={name}
559
+ control={control}
560
+ render={({ field, fieldState }) => (
561
+ <div className="space-y-1">
562
+ <label htmlFor={name} className="block text-sm font-medium text-slate-700 dark:text-slate-200">
563
+ {label}
564
+ {required && <span className="ml-0.5 text-danger">*</span>}
565
+ </label>
566
+ <textarea
567
+ {...field}
568
+ id={name}
569
+ rows={rows}
570
+ maxLength={maxLength}
571
+ aria-invalid={!!fieldState.error}
572
+ className={cn(
573
+ 'block w-full rounded border bg-surface px-3 py-2 text-sm text-slate-900 shadow-sm',
574
+ 'focus:border-primary focus:ring-1 focus:ring-primary dark:text-slate-100',
575
+ fieldState.error ? 'border-danger' : 'border-slate-300 dark:border-slate-600',
576
+ )}
577
+ />
578
+ {(fieldState.error?.message ?? helperText) && (
579
+ <p className={cn('text-xs', fieldState.error ? 'text-danger' : 'text-slate-500')}>
580
+ {fieldState.error?.message ?? helperText}
581
+ </p>
582
+ )}
583
+ </div>
584
+ )}
585
+ />
586
+ );
587
+ }
588
+ ```
589
+
590
+ ```tsx
591
+ // src/shared/form/SelectController.tsx — Headless UI Listbox
592
+ import { Controller, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
593
+ import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
594
+ import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
595
+ import { cn } from '../../lib/utils/cn';
596
+
597
+ export interface SelectOption {
598
+ value: string | number;
599
+ label: string;
600
+ }
601
+
602
+ interface SelectControllerProps<T extends FieldValues> {
603
+ name: FieldPath<T>;
604
+ control: Control<T>;
605
+ label: string;
606
+ options: SelectOption[];
607
+ required?: boolean;
608
+ helperText?: string;
609
+ placeholder?: string;
610
+ }
611
+
612
+ export function SelectController<T extends FieldValues>({
613
+ name,
614
+ control,
615
+ label,
616
+ options,
617
+ required,
618
+ helperText,
619
+ placeholder = 'Select…',
620
+ }: SelectControllerProps<T>) {
621
+ return (
622
+ <Controller
623
+ name={name}
624
+ control={control}
625
+ render={({ field, fieldState }) => {
626
+ const selected = options.find((o) => o.value === field.value);
627
+ return (
628
+ <div className="space-y-1">
629
+ <label className="block text-sm font-medium text-slate-700 dark:text-slate-200">
630
+ {label}
631
+ {required && <span className="ml-0.5 text-danger">*</span>}
632
+ </label>
633
+ <Listbox value={field.value} onChange={field.onChange}>
634
+ <div className="relative">
635
+ <ListboxButton
636
+ className={cn(
637
+ 'relative w-full cursor-default rounded border bg-surface py-2 pl-3 pr-10 text-left text-sm shadow-sm',
638
+ 'focus:border-primary focus:ring-1 focus:ring-primary',
639
+ fieldState.error ? 'border-danger' : 'border-slate-300 dark:border-slate-600',
640
+ )}
641
+ >
642
+ <span className={cn('block truncate', !selected && 'text-slate-400')}>
643
+ {selected?.label ?? placeholder}
644
+ </span>
645
+ <ChevronUpDownIcon className="pointer-events-none absolute right-2 top-2.5 h-5 w-5 text-slate-400" />
646
+ </ListboxButton>
647
+ <ListboxOptions
648
+ anchor="bottom"
649
+ className="z-50 mt-1 max-h-60 w-[var(--button-width)] overflow-auto rounded border border-slate-200 bg-surface py-1 text-sm shadow-lg focus:outline-none dark:border-slate-700"
650
+ >
651
+ {options.map((opt) => (
652
+ <ListboxOption
653
+ key={opt.value}
654
+ value={opt.value}
655
+ className="group flex cursor-default items-center gap-2 px-3 py-2 data-[focus]:bg-primary data-[focus]:text-white"
656
+ >
657
+ <CheckIcon className="invisible h-4 w-4 group-data-[selected]:visible" />
658
+ <span className="truncate">{opt.label}</span>
659
+ </ListboxOption>
660
+ ))}
661
+ </ListboxOptions>
662
+ </div>
663
+ </Listbox>
664
+ {(fieldState.error?.message ?? helperText) && (
665
+ <p className={cn('text-xs', fieldState.error ? 'text-danger' : 'text-slate-500')}>
666
+ {fieldState.error?.message ?? helperText}
667
+ </p>
668
+ )}
669
+ </div>
670
+ );
671
+ }}
672
+ />
673
+ );
674
+ }
675
+ ```
676
+
677
+ ```tsx
678
+ // src/shared/form/SwitchController.tsx — Headless UI Switch
679
+ import { Controller, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
680
+ import { Switch } from '@headlessui/react';
681
+ import { cn } from '../../lib/utils/cn';
682
+
683
+ interface SwitchControllerProps<T extends FieldValues> {
684
+ name: FieldPath<T>;
685
+ control: Control<T>;
686
+ label: string;
687
+ }
688
+
689
+ export function SwitchController<T extends FieldValues>({
690
+ name,
691
+ control,
692
+ label,
693
+ }: SwitchControllerProps<T>) {
694
+ return (
695
+ <Controller
696
+ name={name}
697
+ control={control}
698
+ render={({ field }) => (
699
+ <div className="flex items-center justify-between">
700
+ <span className="text-sm font-medium text-slate-700 dark:text-slate-200">{label}</span>
701
+ <Switch
702
+ checked={!!field.value}
703
+ onChange={field.onChange}
704
+ className={cn(
705
+ 'group relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
706
+ 'bg-slate-300 data-[checked]:bg-primary dark:bg-slate-600',
707
+ )}
708
+ >
709
+ <span className="inline-block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform group-data-[checked]:translate-x-6" />
710
+ </Switch>
711
+ </div>
712
+ )}
713
+ />
714
+ );
715
+ }
716
+ ```
717
+
718
+ > `DatePickerController` (react-day-picker inside a Headless UI `Popover`),
719
+ > `ComboboxController` (Headless UI `Combobox`), and `RichTextController` (Tiptap) follow the
720
+ > same `Controller`-wrapped pattern. Generate them when `DatePickers`, autocomplete fields, or
721
+ > `RichText` are required.
722
+
723
+ ---
724
+
725
+ ## Notification Provider (Headless UI Transition toasts)
726
+
727
+ ```tsx
728
+ // src/shared/components/NotificationProvider.tsx
729
+ import { Transition } from '@headlessui/react';
730
+ import {
731
+ CheckCircleIcon,
732
+ ExclamationTriangleIcon,
733
+ InformationCircleIcon,
734
+ XCircleIcon,
735
+ XMarkIcon,
736
+ } from '@heroicons/react/24/outline';
737
+ import { cn } from '../../lib/utils/cn';
738
+ import { useNotificationStore, type Notification } from '../../store/notification.store';
739
+
740
+ const TOAST_STYLES: Record<Notification['type'], { icon: typeof CheckCircleIcon; classes: string }> = {
741
+ success: { icon: CheckCircleIcon, classes: 'border-success/40 text-success' },
742
+ error: { icon: XCircleIcon, classes: 'border-danger/40 text-danger' },
743
+ warning: { icon: ExclamationTriangleIcon, classes: 'border-warning/40 text-warning' },
744
+ info: { icon: InformationCircleIcon, classes: 'border-primary/40 text-primary' },
745
+ };
746
+
747
+ export function NotificationProvider() {
748
+ const notifications = useNotificationStore((s) => s.notifications);
749
+ const dismiss = useNotificationStore((s) => s.dismiss);
750
+
751
+ return (
752
+ <div className="pointer-events-none fixed bottom-6 right-6 z-[9999] flex w-full max-w-sm flex-col gap-2">
753
+ {notifications.map((n) => {
754
+ const { icon: Icon, classes } = TOAST_STYLES[n.type];
755
+ return (
756
+ <Transition
757
+ key={n.id}
758
+ appear
759
+ show
760
+ enter="transition duration-200 ease-out"
761
+ enterFrom="translate-y-2 opacity-0"
762
+ enterTo="translate-y-0 opacity-100"
763
+ leave="transition duration-150 ease-in"
764
+ leaveFrom="opacity-100"
765
+ leaveTo="opacity-0"
766
+ >
767
+ <div
768
+ role="alert"
769
+ className={cn(
770
+ 'pointer-events-auto flex items-start gap-3 rounded border bg-surface p-3 shadow-lg',
771
+ classes,
772
+ )}
773
+ >
774
+ <Icon className="mt-0.5 h-5 w-5 shrink-0" />
775
+ <p className="flex-1 text-sm text-slate-800 dark:text-slate-100">{n.message}</p>
776
+ <button
777
+ type="button"
778
+ onClick={() => dismiss(n.id)}
779
+ className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"
780
+ aria-label="Dismiss"
781
+ >
782
+ <XMarkIcon className="h-4 w-4" />
783
+ </button>
784
+ </div>
785
+ </Transition>
786
+ );
787
+ })}
788
+ </div>
789
+ );
790
+ }
791
+ ```
792
+
793
+ ---
794
+
795
+ ## Status Badge (cva variants)
796
+
797
+ ```tsx
798
+ // src/shared/components/StatusBadge.tsx
799
+ import { cva, type VariantProps } from 'class-variance-authority';
800
+ import { cn } from '../../lib/utils/cn';
801
+
802
+ const badgeVariants = cva(
803
+ 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
804
+ {
805
+ variants: {
806
+ variant: {
807
+ neutral: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
808
+ primary: 'bg-primary/10 text-primary',
809
+ success: 'bg-success/10 text-success',
810
+ warning: 'bg-warning/10 text-warning',
811
+ danger: 'bg-danger/10 text-danger',
812
+ },
813
+ },
814
+ defaultVariants: { variant: 'neutral' },
815
+ },
816
+ );
817
+
818
+ export type StatusVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>;
819
+
820
+ interface StatusConfig {
821
+ label: string;
822
+ variant: StatusVariant;
823
+ }
824
+
825
+ interface StatusBadgeProps {
826
+ status: string;
827
+ /** Map of status value → display config. Provide this per-module. */
828
+ statusMap: Record<string, StatusConfig>;
829
+ className?: string;
830
+ }
831
+
832
+ export function StatusBadge({ status, statusMap, className }: StatusBadgeProps) {
833
+ const config = statusMap[status] ?? { label: status, variant: 'neutral' as const };
834
+ return <span className={cn(badgeVariants({ variant: config.variant }), className)}>{config.label}</span>;
835
+ }
836
+
837
+ // Example usage in a module:
838
+ // const HERO_STATUS_MAP = {
839
+ // DRAFT: { label: 'Draft', variant: 'neutral' },
840
+ // ACTIVE: { label: 'Active', variant: 'success' },
841
+ // EXPIRED: { label: 'Expired', variant: 'danger' },
842
+ // };
843
+ // <StatusBadge status={row.status} statusMap={HERO_STATUS_MAP} />
844
+ ```
845
+
846
+ ---
847
+
848
+ ## Confirm Dialog (Headless UI Dialog + imperative hook)
849
+
850
+ ```tsx
851
+ // src/shared/components/ConfirmDialog.tsx
852
+ import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react';
853
+ import { create } from 'zustand';
854
+ import { Button } from './Button';
855
+
856
+ interface ConfirmState {
857
+ open: boolean;
858
+ title: string;
859
+ message: string;
860
+ confirmLabel: string;
861
+ cancelLabel: string;
862
+ onConfirm: (() => void) | null;
863
+ show: (options: Omit<ConfirmState, 'open' | 'show' | 'hide'>) => void;
864
+ hide: () => void;
865
+ }
866
+
867
+ const useConfirmStore = create<ConfirmState>((set) => ({
868
+ open: false,
869
+ title: '',
870
+ message: '',
871
+ confirmLabel: 'Confirm',
872
+ cancelLabel: 'Cancel',
873
+ onConfirm: null,
874
+ show: (options) => set({ open: true, ...options }),
875
+ hide: () => set({ open: false, onConfirm: null }),
876
+ }));
877
+
878
+ /** Hook to imperatively trigger a confirmation dialog. Resolves true when confirmed. */
879
+ export function useConfirm() {
880
+ const show = useConfirmStore((s) => s.show);
881
+
882
+ return (options: {
883
+ title?: string;
884
+ message: string;
885
+ confirmLabel?: string;
886
+ cancelLabel?: string;
887
+ }): Promise<boolean> =>
888
+ new Promise((resolve) => {
889
+ show({
890
+ title: options.title ?? 'Confirm Action',
891
+ message: options.message,
892
+ confirmLabel: options.confirmLabel ?? 'Confirm',
893
+ cancelLabel: options.cancelLabel ?? 'Cancel',
894
+ onConfirm: () => resolve(true),
895
+ });
896
+ });
897
+ }
898
+
899
+ /** Render this once at the app root to display the dialog. */
900
+ export function ConfirmDialog() {
901
+ const { open, title, message, confirmLabel, cancelLabel, onConfirm, hide } = useConfirmStore();
902
+
903
+ const handleConfirm = () => {
904
+ onConfirm?.();
905
+ hide();
906
+ };
907
+
908
+ return (
909
+ <Dialog open={open} onClose={hide} className="relative z-[1000]">
910
+ <DialogBackdrop
911
+ transition
912
+ className="fixed inset-0 bg-black/40 transition-opacity duration-200 data-[closed]:opacity-0"
913
+ />
914
+ <div className="fixed inset-0 flex items-center justify-center p-4">
915
+ <DialogPanel
916
+ transition
917
+ className="w-full max-w-sm rounded-lg bg-surface p-6 shadow-xl transition duration-200 data-[closed]:scale-95 data-[closed]:opacity-0"
918
+ >
919
+ <DialogTitle className="text-lg font-semibold text-slate-900 dark:text-slate-100">
920
+ {title}
921
+ </DialogTitle>
922
+ <p className="mt-2 text-sm text-slate-600 dark:text-slate-300">{message}</p>
923
+ <div className="mt-6 flex justify-end gap-3">
924
+ <Button variant="outline" onClick={hide}>
925
+ {cancelLabel}
926
+ </Button>
927
+ <Button variant="danger" onClick={handleConfirm} autoFocus>
928
+ {confirmLabel}
929
+ </Button>
930
+ </div>
931
+ </DialogPanel>
932
+ </div>
933
+ </Dialog>
934
+ );
935
+ }
936
+ ```
937
+
938
+ ---
939
+
940
+ ## API Function Pattern
941
+
942
+ ```typescript
943
+ // Example: src/features/heroSection/api/heroSectionApi.ts
944
+ import { axiosInstance } from '../../../lib/api/axiosInstance';
945
+ import type {
946
+ HeroSection,
947
+ HeroSectionCreate,
948
+ HeroSectionUpdate,
949
+ HeroSectionListParams,
950
+ PaginatedResponse,
951
+ } from '../heroSection.types';
952
+
953
+ export const heroSectionApi = {
954
+ /** Fetch paginated list of hero sections. */
955
+ list: async (params: HeroSectionListParams): Promise<PaginatedResponse<HeroSection>> => {
956
+ const { data } = await axiosInstance.get<PaginatedResponse<HeroSection>>('/hero-sections', {
957
+ params,
958
+ });
959
+ return data;
960
+ },
961
+
962
+ /** Fetch a single hero section by ID. */
963
+ getById: async (id: string): Promise<HeroSection> => {
964
+ const { data } = await axiosInstance.get<HeroSection>(`/hero-sections/${id}`);
965
+ return data;
966
+ },
967
+
968
+ /** Create a new hero section with an optional image upload (multipart). */
969
+ create: async (payload: HeroSectionCreate, imageFile?: File): Promise<HeroSection> => {
970
+ if (imageFile) {
971
+ const formData = new FormData();
972
+ formData.append('data', JSON.stringify(payload));
973
+ formData.append('image', imageFile);
974
+ const { data } = await axiosInstance.post<HeroSection>('/hero-sections', formData, {
975
+ headers: { 'Content-Type': 'multipart/form-data' },
976
+ });
977
+ return data;
978
+ }
979
+ const { data } = await axiosInstance.post<HeroSection>('/hero-sections', payload);
980
+ return data;
981
+ },
982
+
983
+ /** Update an existing hero section. */
984
+ update: async (id: string, payload: HeroSectionUpdate, imageFile?: File): Promise<HeroSection> => {
985
+ if (imageFile) {
986
+ const formData = new FormData();
987
+ formData.append('data', JSON.stringify(payload));
988
+ formData.append('image', imageFile);
989
+ const { data } = await axiosInstance.put<HeroSection>(`/hero-sections/${id}`, formData, {
990
+ headers: { 'Content-Type': 'multipart/form-data' },
991
+ });
992
+ return data;
993
+ }
994
+ const { data } = await axiosInstance.put<HeroSection>(`/hero-sections/${id}`, payload);
995
+ return data;
996
+ },
997
+
998
+ /** Delete a hero section by ID. */
999
+ delete: async (id: string): Promise<void> => {
1000
+ await axiosInstance.delete(`/hero-sections/${id}`);
1001
+ },
1002
+
1003
+ /** Serve image by ID (returns a URL for use in <img src="...">). */
1004
+ imageUrl: (id: string): string =>
1005
+ `${import.meta.env.VITE_API_BASE_URL}/hero-sections/${id}/image`,
1006
+ };
1007
+ ```
1008
+
1009
+ ---
1010
+
1011
+ ## TypeScript Types Pattern
1012
+
1013
+ ```typescript
1014
+ // Example: src/features/heroSection/heroSection.types.ts
1015
+
1016
+ /** Enum derived from PRD.md constraint for hero section status */
1017
+ export type HeroSectionStatus = 'DRAFT' | 'ACTIVE' | 'EXPIRED';
1018
+
1019
+ /** Full hero section entity returned by the API */
1020
+ export interface HeroSection {
1021
+ id: string;
1022
+ headline: string;
1023
+ subheadline: string;
1024
+ ctaText: string;
1025
+ ctaUrl: string;
1026
+ effectiveDate: string; // ISO date string (YYYY-MM-DD)
1027
+ expirationDate: string; // ISO date string
1028
+ status: HeroSectionStatus;
1029
+ createdAt: string; // ISO datetime string
1030
+ updatedAt: string;
1031
+ }
1032
+
1033
+ /** Payload for creating a hero section (image uploaded separately) */
1034
+ export interface HeroSectionCreate {
1035
+ headline: string;
1036
+ subheadline: string;
1037
+ ctaText: string;
1038
+ ctaUrl: string;
1039
+ effectiveDate: string;
1040
+ expirationDate: string;
1041
+ }
1042
+
1043
+ /** Payload for updating a hero section */
1044
+ export type HeroSectionUpdate = Partial<HeroSectionCreate>;
1045
+
1046
+ /** Query parameters for the list endpoint */
1047
+ export interface HeroSectionListParams {
1048
+ page?: number;
1049
+ size?: number;
1050
+ status?: HeroSectionStatus | '';
1051
+ effectiveDateFrom?: string;
1052
+ effectiveDateTo?: string;
1053
+ }
1054
+
1055
+ /** Generic paginated response wrapper — matches the backend API contract */
1056
+ export interface PaginatedResponse<T> {
1057
+ content: T[];
1058
+ page: number;
1059
+ size: number;
1060
+ totalElements: number;
1061
+ totalPages: number;
1062
+ }
1063
+ ```
1064
+
1065
+ ---
1066
+
1067
+ ## Page Component Pattern (Tailwind table list page)
1068
+
1069
+ ```tsx
1070
+ // Example: src/features/heroSection/pages/HeroSectionListPage.tsx
1071
+ import { useState } from 'react';
1072
+ import { useNavigate } from 'react-router-dom';
1073
+ import { PlusIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline';
1074
+ import { Button } from '../../../shared/components/Button';
1075
+ import { Card } from '../../../shared/components/Card';
1076
+ import { PageHeader } from '../../../shared/components/PageHeader';
1077
+ import { StatusBadge } from '../../../shared/components/StatusBadge';
1078
+ import { LoadingOverlay } from '../../../shared/components/LoadingOverlay';
1079
+ import { EmptyState } from '../../../shared/components/EmptyState';
1080
+ import { Pagination } from '../../../shared/components/Pagination';
1081
+ import { useConfirm } from '../../../shared/components/ConfirmDialog';
1082
+ import { useHeroSections } from '../hooks/useHeroSections';
1083
+ import { useDeleteHeroSection } from '../hooks/useHeroSectionMutations';
1084
+ import type { HeroSectionListParams, HeroSectionStatus } from '../heroSection.types';
1085
+
1086
+ const HERO_STATUS_MAP = {
1087
+ DRAFT: { label: 'Draft', variant: 'neutral' as const },
1088
+ ACTIVE: { label: 'Active', variant: 'success' as const },
1089
+ EXPIRED: { label: 'Expired', variant: 'danger' as const },
1090
+ };
1091
+
1092
+ export function HeroSectionListPage() {
1093
+ const navigate = useNavigate();
1094
+ const confirm = useConfirm();
1095
+
1096
+ const [params, setParams] = useState<HeroSectionListParams>({ page: 0, size: 10, status: '' });
1097
+
1098
+ const { data, isLoading, isError } = useHeroSections(params);
1099
+ const deleteMutation = useDeleteHeroSection();
1100
+
1101
+ const handleDelete = async (id: string, headline: string) => {
1102
+ const confirmed = await confirm({
1103
+ title: 'Delete Hero Section',
1104
+ message: `Are you sure you want to delete "${headline}"? This action cannot be undone.`,
1105
+ confirmLabel: 'Delete',
1106
+ });
1107
+ if (confirmed) deleteMutation.mutate(id);
1108
+ };
1109
+
1110
+ if (isLoading) return <LoadingOverlay />;
1111
+ if (isError) return <p className="text-danger">Failed to load hero sections.</p>;
1112
+
1113
+ return (
1114
+ <div>
1115
+ <PageHeader
1116
+ title="Hero Sections"
1117
+ breadcrumbs={[{ label: 'Dashboard', path: '/dashboard' }, { label: 'Hero Sections' }]}
1118
+ actions={
1119
+ <Button onClick={() => navigate('/hero-section/create')}>
1120
+ <PlusIcon className="h-4 w-4" />
1121
+ Add Hero Section
1122
+ </Button>
1123
+ }
1124
+ />
1125
+
1126
+ {/* Filters */}
1127
+ <div className="mb-4 flex flex-wrap gap-3">
1128
+ <select
1129
+ value={params.status ?? ''}
1130
+ onChange={(e) =>
1131
+ setParams((p) => ({ ...p, status: e.target.value as HeroSectionStatus | '', page: 0 }))
1132
+ }
1133
+ className="rounded border border-slate-300 bg-surface px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-primary dark:border-slate-600"
1134
+ >
1135
+ <option value="">All Statuses</option>
1136
+ <option value="DRAFT">Draft</option>
1137
+ <option value="ACTIVE">Active</option>
1138
+ <option value="EXPIRED">Expired</option>
1139
+ </select>
1140
+ </div>
1141
+
1142
+ <Card className="overflow-hidden">
1143
+ <div className="overflow-x-auto">
1144
+ <table className="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
1145
+ <thead className="bg-canvas">
1146
+ <tr>
1147
+ <th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-300">Headline</th>
1148
+ <th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-300">Effective Date</th>
1149
+ <th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-300">Expiration Date</th>
1150
+ <th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-300">Status</th>
1151
+ <th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-300">Actions</th>
1152
+ </tr>
1153
+ </thead>
1154
+ <tbody className="divide-y divide-slate-100 dark:divide-slate-800">
1155
+ {data?.content.length === 0 ? (
1156
+ <tr>
1157
+ <td colSpan={5}>
1158
+ <EmptyState message="No hero sections found." />
1159
+ </td>
1160
+ </tr>
1161
+ ) : (
1162
+ data?.content.map((row) => (
1163
+ <tr key={row.id} className="hover:bg-canvas/60">
1164
+ <td className="px-4 py-3 text-sm text-slate-900 dark:text-slate-100">{row.headline}</td>
1165
+ <td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-300">{row.effectiveDate}</td>
1166
+ <td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-300">{row.expirationDate}</td>
1167
+ <td className="px-4 py-3">
1168
+ <StatusBadge status={row.status} statusMap={HERO_STATUS_MAP} />
1169
+ </td>
1170
+ <td className="px-4 py-3 text-right">
1171
+ <div className="flex justify-end gap-1">
1172
+ <Button
1173
+ variant="ghost"
1174
+ size="icon"
1175
+ aria-label="Edit"
1176
+ onClick={() => navigate(`/hero-section/${row.id}/edit`)}
1177
+ >
1178
+ <PencilSquareIcon className="h-4 w-4" />
1179
+ </Button>
1180
+ <Button
1181
+ variant="ghost"
1182
+ size="icon"
1183
+ aria-label="Delete"
1184
+ className="text-danger"
1185
+ onClick={() => handleDelete(row.id, row.headline)}
1186
+ >
1187
+ <TrashIcon className="h-4 w-4" />
1188
+ </Button>
1189
+ </div>
1190
+ </td>
1191
+ </tr>
1192
+ ))
1193
+ )}
1194
+ </tbody>
1195
+ </table>
1196
+ </div>
1197
+ <Pagination
1198
+ page={params.page ?? 0}
1199
+ size={params.size ?? 10}
1200
+ totalElements={data?.totalElements ?? 0}
1201
+ onPageChange={(page) => setParams((p) => ({ ...p, page }))}
1202
+ onSizeChange={(size) => setParams((p) => ({ ...p, size, page: 0 }))}
1203
+ />
1204
+ </Card>
1205
+ </div>
1206
+ );
1207
+ }
1208
+ ```
1209
+
1210
+ > For grids needing column sorting, multi-column filtering, or row selection, replace the
1211
+ > raw `<table>` with the `DataTable` component built on **TanStack Table** (`@tanstack/react-table`).
1212
+ > See `routing-patterns.md` for the `DataTable` and `Pagination` shared-component implementations.