@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.
- package/dist/index.js +25449 -0
- package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
- package/drizzle/0001_wise_wolf_cub.sql +13 -0
- package/drizzle/0002_secret_spyke.sql +16 -0
- package/drizzle/0003_workspace_config_singleton.sql +16 -0
- package/drizzle/0004_terminal_claim_v120.sql +11 -0
- package/drizzle/0005_brainstorm_v124.sql +10 -0
- package/drizzle/0006_project_v140.sql +6 -0
- package/drizzle/0007_prd_wizard_v124.sql +14 -0
- package/drizzle/0008_workflow_artifact_v126.sql +24 -0
- package/drizzle/0009_project_import_v150.sql +7 -0
- package/drizzle/0010_quality_v151.sql +3 -0
- package/drizzle/meta/0000_snapshot.json +1335 -0
- package/drizzle/meta/0001_snapshot.json +1416 -0
- package/drizzle/meta/0002_snapshot.json +1510 -0
- package/drizzle/meta/_journal.json +83 -0
- package/package.json +41 -0
- package/plugin/.claude-plugin/marketplace.json +36 -0
- package/plugin/.claude-plugin/plugin.json +22 -0
- package/plugin/README.md +625 -0
- package/plugin/SKILLS.md +350 -0
- package/plugin/co2.svg +5 -0
- package/plugin/commands/brainstorm-loop.md +129 -0
- package/plugin/skills/conductor-defect/SKILL.md +898 -0
- package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
- package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
- package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
- package/plugin/skills/depgen-k8s/SKILL.md +468 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
- package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
- package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
- package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
- package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
- package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
- package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
- package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
- package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
- package/plugin/skills/modelgen-relational/SKILL.md +707 -0
- package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
- package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
- package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
- package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
- package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
- package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
- package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
- package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
- package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
- package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
- package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
- package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
- package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
- package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
- package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
- package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
- package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
- package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
- package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
- package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
- package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
- package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
- package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
- package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
- package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
- package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
- package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
- package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
- package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
- package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
- package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
- package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
- package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
- package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
- package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
- package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
- package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
- package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
- package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
- package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
- package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
- package/plugin/skills/testgen-functional/SKILL.md +1381 -0
- package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
- package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
- package/plugin/skills/util-projectinit/SKILL.md +307 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
- package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
- package/plugin/skills/util-projectsync/SKILL.md +501 -0
- package/plugin/skills/util-updprd/SKILL.md +324 -0
- package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
- package/plugin/skills/util-ustagger/SKILL.md +154 -0
- package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
- package/static/assets/arc-DRKw7sjs.js +1 -0
- package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
- package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
- package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
- package/static/assets/channel-C2nVbuwv.js +1 -0
- package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
- package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
- package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
- package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
- package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
- package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
- package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
- package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
- package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
- package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
- package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
- package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
- package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
- package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
- package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
- package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
- package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
- package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
- package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
- package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
- package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
- package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
- package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
- package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
- package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
- package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
- package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
- package/static/assets/graph-C9eacEi8.js +1 -0
- package/static/assets/index-LZSQkLE2.css +41 -0
- package/static/assets/index-y7XDSgMG.js +495 -0
- package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
- package/static/assets/init-Gi6I4Gst.js +1 -0
- package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
- package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
- package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
- package/static/assets/katex-C5jXJg4s.js +257 -0
- package/static/assets/layout-DEXfKzaS.js +1 -0
- package/static/assets/linear-CLO--ryk.js +1 -0
- package/static/assets/map-Czzmt4hB.js +1 -0
- package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
- package/static/assets/ordinal-Cboi1Yqb.js +1 -0
- package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
- package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
- package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
- package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
- package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
- package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
- package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
- package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
- package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
- package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
- package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
- package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
- package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
- package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
- package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
- package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
- 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.
|