@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,1077 @@
|
|
|
1
|
+
# Component Patterns — MUI Theme, 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, and 14 of the generated specification.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## MUI Theme Configuration
|
|
9
|
+
|
|
10
|
+
### Theme Setup
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
// src/theme/palette.ts
|
|
14
|
+
import type { PaletteOptions } from '@mui/material/styles';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Design tokens extracted from the application mockup.
|
|
18
|
+
* Replace hex values with those found in MOCKUP.html CSS / inline styles.
|
|
19
|
+
*/
|
|
20
|
+
export const palette: PaletteOptions = {
|
|
21
|
+
primary: {
|
|
22
|
+
main: '{{PRIMARY_COLOR}}', // e.g., '#2271b1' from mockup
|
|
23
|
+
dark: '{{PRIMARY_DARK_COLOR}}', // e.g., '#135e96' from mockup (hover state)
|
|
24
|
+
contrastText: '#ffffff',
|
|
25
|
+
},
|
|
26
|
+
secondary: {
|
|
27
|
+
main: '{{SECONDARY_COLOR}}',
|
|
28
|
+
contrastText: '#ffffff',
|
|
29
|
+
},
|
|
30
|
+
error: {
|
|
31
|
+
main: '{{ERROR_COLOR}}', // e.g., '#d63638' from mockup
|
|
32
|
+
},
|
|
33
|
+
warning: {
|
|
34
|
+
main: '{{WARNING_COLOR}}', // e.g., '#dba617' from mockup
|
|
35
|
+
},
|
|
36
|
+
success: {
|
|
37
|
+
main: '{{SUCCESS_COLOR}}', // e.g., '#00a32a' from mockup
|
|
38
|
+
},
|
|
39
|
+
background: {
|
|
40
|
+
default: '{{PAGE_BACKGROUND}}', // e.g., '#f0f0f1' from mockup
|
|
41
|
+
paper: '{{SURFACE_BACKGROUND}}', // e.g., '#ffffff' from mockup
|
|
42
|
+
},
|
|
43
|
+
text: {
|
|
44
|
+
primary: '{{TEXT_PRIMARY}}', // e.g., '#1e1e1e' from mockup
|
|
45
|
+
secondary: '{{TEXT_SECONDARY}}', // e.g., '#646970' from mockup
|
|
46
|
+
},
|
|
47
|
+
divider: '{{BORDER_COLOR}}', // e.g., '#c3c4c7' from mockup
|
|
48
|
+
};
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
// src/theme/typography.ts
|
|
53
|
+
import type { TypographyOptions } from '@mui/material/styles/createTypography';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Typography tokens extracted from the application mockup.
|
|
57
|
+
*/
|
|
58
|
+
export const typography: TypographyOptions = {
|
|
59
|
+
// Extract font family from mockup CSS font-family declaration
|
|
60
|
+
fontFamily: '{{FONT_FAMILY}}', // e.g., '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ...'
|
|
61
|
+
h1: { fontSize: '2rem', fontWeight: 700 },
|
|
62
|
+
h2: { fontSize: '1.75rem', fontWeight: 700 },
|
|
63
|
+
h3: { fontSize: '1.5rem', fontWeight: 600 },
|
|
64
|
+
h4: { fontSize: '1.25rem', fontWeight: 600 },
|
|
65
|
+
h5: { fontSize: '1.125rem', fontWeight: 600 },
|
|
66
|
+
h6: { fontSize: '1rem', fontWeight: 600 },
|
|
67
|
+
body1: { fontSize: '1rem' },
|
|
68
|
+
body2: { fontSize: '0.875rem' },
|
|
69
|
+
caption: { fontSize: '0.75rem', color: '{{TEXT_SECONDARY}}' },
|
|
70
|
+
};
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
// src/theme/index.ts
|
|
75
|
+
import { createTheme } from '@mui/material/styles';
|
|
76
|
+
import { palette } from './palette';
|
|
77
|
+
import { typography } from './typography';
|
|
78
|
+
|
|
79
|
+
export const lightTheme = createTheme({
|
|
80
|
+
palette: { mode: 'light', ...palette },
|
|
81
|
+
typography,
|
|
82
|
+
shape: {
|
|
83
|
+
// Extract border-radius from mockup
|
|
84
|
+
borderRadius: {{BORDER_RADIUS_BASE}}, // e.g., 4
|
|
85
|
+
},
|
|
86
|
+
components: {
|
|
87
|
+
MuiButton: {
|
|
88
|
+
styleOverrides: {
|
|
89
|
+
root: {
|
|
90
|
+
borderRadius: {{BORDER_RADIUS_BUTTON}}, // e.g., 4 for buttons
|
|
91
|
+
textTransform: 'none',
|
|
92
|
+
fontWeight: 600,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
defaultProps: {
|
|
96
|
+
disableElevation: true,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
MuiCard: {
|
|
100
|
+
styleOverrides: {
|
|
101
|
+
root: {
|
|
102
|
+
borderRadius: {{BORDER_RADIUS_CARD}}, // e.g., 8 for cards
|
|
103
|
+
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
MuiTextField: {
|
|
108
|
+
defaultProps: {
|
|
109
|
+
size: 'small',
|
|
110
|
+
variant: 'outlined',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
MuiPaper: {
|
|
114
|
+
styleOverrides: {
|
|
115
|
+
root: {
|
|
116
|
+
borderRadius: {{BORDER_RADIUS_CARD}},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
MuiTableCell: {
|
|
121
|
+
styleOverrides: {
|
|
122
|
+
head: {
|
|
123
|
+
fontWeight: 600,
|
|
124
|
+
backgroundColor: '{{PAGE_BACKGROUND}}',
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export const darkTheme = createTheme({
|
|
132
|
+
palette: { mode: 'dark' },
|
|
133
|
+
typography,
|
|
134
|
+
shape: { borderRadius: {{BORDER_RADIUS_BASE}} },
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## TanStack Query Setup
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
// src/lib/queryClient.ts
|
|
144
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
145
|
+
import { useNotificationStore } from '../store/notification.store';
|
|
146
|
+
|
|
147
|
+
export const queryClient = new QueryClient({
|
|
148
|
+
defaultOptions: {
|
|
149
|
+
queries: {
|
|
150
|
+
// Data is considered fresh for 30 seconds — prevents redundant refetches
|
|
151
|
+
staleTime: 30_000,
|
|
152
|
+
// Unused data is garbage collected after 5 minutes
|
|
153
|
+
gcTime: 5 * 60_000,
|
|
154
|
+
// Retry failed queries up to 2 times (not for 4xx errors)
|
|
155
|
+
retry: (failureCount, error) => {
|
|
156
|
+
if (error instanceof Error && 'status' in error) {
|
|
157
|
+
const status = (error as { status: number }).status;
|
|
158
|
+
if (status >= 400 && status < 500) return false;
|
|
159
|
+
}
|
|
160
|
+
return failureCount < 2;
|
|
161
|
+
},
|
|
162
|
+
refetchOnWindowFocus: true,
|
|
163
|
+
},
|
|
164
|
+
mutations: {
|
|
165
|
+
onError: (error) => {
|
|
166
|
+
const message =
|
|
167
|
+
error instanceof Error ? error.message : 'An unexpected error occurred';
|
|
168
|
+
useNotificationStore.getState().push('error', message);
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Standard Query Hook Pattern
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
// Example: src/features/heroSection/hooks/useHeroSections.ts
|
|
179
|
+
import { useQuery } from '@tanstack/react-query';
|
|
180
|
+
import { heroSectionApi } from '../api/heroSectionApi';
|
|
181
|
+
import type { HeroSectionListParams } from '../heroSection.types';
|
|
182
|
+
|
|
183
|
+
export const heroSectionKeys = {
|
|
184
|
+
all: ['heroSections'] as const,
|
|
185
|
+
lists: () => [...heroSectionKeys.all, 'list'] as const,
|
|
186
|
+
list: (params: HeroSectionListParams) => [...heroSectionKeys.lists(), params] as const,
|
|
187
|
+
details: () => [...heroSectionKeys.all, 'detail'] as const,
|
|
188
|
+
detail: (id: string) => [...heroSectionKeys.details(), id] as const,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export function useHeroSections(params: HeroSectionListParams) {
|
|
192
|
+
return useQuery({
|
|
193
|
+
queryKey: heroSectionKeys.list(params),
|
|
194
|
+
queryFn: () => heroSectionApi.list(params),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function useHeroSection(id: string) {
|
|
199
|
+
return useQuery({
|
|
200
|
+
queryKey: heroSectionKeys.detail(id),
|
|
201
|
+
queryFn: () => heroSectionApi.getById(id),
|
|
202
|
+
enabled: !!id,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Standard Mutation Hook Pattern
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
// Example: src/features/heroSection/hooks/useHeroSectionMutations.ts
|
|
211
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
212
|
+
import { heroSectionApi } from '../api/heroSectionApi';
|
|
213
|
+
import { heroSectionKeys } from './useHeroSections';
|
|
214
|
+
import { useNotificationStore } from '../../../store/notification.store';
|
|
215
|
+
import type { HeroSectionCreate, HeroSectionUpdate } from '../heroSection.types';
|
|
216
|
+
|
|
217
|
+
export function useCreateHeroSection() {
|
|
218
|
+
const queryClient = useQueryClient();
|
|
219
|
+
const { push } = useNotificationStore();
|
|
220
|
+
|
|
221
|
+
return useMutation({
|
|
222
|
+
mutationFn: (data: HeroSectionCreate) => heroSectionApi.create(data),
|
|
223
|
+
onSuccess: () => {
|
|
224
|
+
// Invalidate the list query so it refetches with the new item
|
|
225
|
+
queryClient.invalidateQueries({ queryKey: heroSectionKeys.lists() });
|
|
226
|
+
push('success', 'Hero section created successfully.');
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function useUpdateHeroSection() {
|
|
232
|
+
const queryClient = useQueryClient();
|
|
233
|
+
const { push } = useNotificationStore();
|
|
234
|
+
|
|
235
|
+
return useMutation({
|
|
236
|
+
mutationFn: ({ id, data }: { id: string; data: HeroSectionUpdate }) =>
|
|
237
|
+
heroSectionApi.update(id, data),
|
|
238
|
+
onSuccess: (_, { id }) => {
|
|
239
|
+
queryClient.invalidateQueries({ queryKey: heroSectionKeys.detail(id) });
|
|
240
|
+
queryClient.invalidateQueries({ queryKey: heroSectionKeys.lists() });
|
|
241
|
+
push('success', 'Hero section updated successfully.');
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function useDeleteHeroSection() {
|
|
247
|
+
const queryClient = useQueryClient();
|
|
248
|
+
const { push } = useNotificationStore();
|
|
249
|
+
|
|
250
|
+
return useMutation({
|
|
251
|
+
mutationFn: (id: string) => heroSectionApi.delete(id),
|
|
252
|
+
onSuccess: () => {
|
|
253
|
+
queryClient.invalidateQueries({ queryKey: heroSectionKeys.lists() });
|
|
254
|
+
push('success', 'Hero section deleted.');
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Zustand Stores
|
|
263
|
+
|
|
264
|
+
### Notification Store
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
// src/store/notification.store.ts
|
|
268
|
+
import { create } from 'zustand';
|
|
269
|
+
|
|
270
|
+
type NotificationType = 'success' | 'error' | 'warning' | 'info';
|
|
271
|
+
|
|
272
|
+
export interface Notification {
|
|
273
|
+
id: string;
|
|
274
|
+
type: NotificationType;
|
|
275
|
+
message: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface NotificationState {
|
|
279
|
+
notifications: Notification[];
|
|
280
|
+
push: (type: NotificationType, message: string) => void;
|
|
281
|
+
dismiss: (id: string) => void;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export const useNotificationStore = create<NotificationState>((set) => ({
|
|
285
|
+
notifications: [],
|
|
286
|
+
|
|
287
|
+
push: (type, message) => {
|
|
288
|
+
const id = crypto.randomUUID();
|
|
289
|
+
set((state) => ({ notifications: [...state.notifications, { id, type, message }] }));
|
|
290
|
+
// Auto-dismiss after 5 seconds
|
|
291
|
+
setTimeout(() => {
|
|
292
|
+
set((state) => ({
|
|
293
|
+
notifications: state.notifications.filter((n) => n.id !== id),
|
|
294
|
+
}));
|
|
295
|
+
}, 5_000);
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
dismiss: (id) =>
|
|
299
|
+
set((state) => ({ notifications: state.notifications.filter((n) => n.id !== id) })),
|
|
300
|
+
}));
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### UI Store
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
// src/store/ui.store.ts
|
|
307
|
+
import { create } from 'zustand';
|
|
308
|
+
import { persist } from 'zustand/middleware';
|
|
309
|
+
|
|
310
|
+
interface UiState {
|
|
311
|
+
sidebarOpen: boolean;
|
|
312
|
+
themeMode: 'light' | 'dark';
|
|
313
|
+
toggleSidebar: () => void;
|
|
314
|
+
toggleThemeMode: () => void;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export const useUiStore = create<UiState>()(
|
|
318
|
+
persist(
|
|
319
|
+
(set) => ({
|
|
320
|
+
sidebarOpen: true,
|
|
321
|
+
themeMode: 'light',
|
|
322
|
+
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
|
323
|
+
toggleThemeMode: () =>
|
|
324
|
+
set((state) => ({ themeMode: state.themeMode === 'light' ? 'dark' : 'light' })),
|
|
325
|
+
}),
|
|
326
|
+
{ name: 'ui-preferences' }, // Persisted to localStorage (safe — no tokens here)
|
|
327
|
+
),
|
|
328
|
+
);
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## React Hook Form + Zod Patterns
|
|
334
|
+
|
|
335
|
+
### Zod Schema Example
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
// Example: src/features/heroSection/heroSection.schema.ts
|
|
339
|
+
import { z } from 'zod';
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Validation schema for hero section create/update form.
|
|
343
|
+
* Rules derived from PRD.md NFRs and constraints.
|
|
344
|
+
*/
|
|
345
|
+
export const heroSectionSchema = z
|
|
346
|
+
.object({
|
|
347
|
+
headline: z
|
|
348
|
+
.string()
|
|
349
|
+
.min(1, 'Headline is required')
|
|
350
|
+
.max(100, 'Headline must be at most 100 characters'),
|
|
351
|
+
subheadline: z
|
|
352
|
+
.string()
|
|
353
|
+
.min(1, 'Subheadline is required')
|
|
354
|
+
.max(200, 'Subheadline must be at most 200 characters'),
|
|
355
|
+
ctaText: z
|
|
356
|
+
.string()
|
|
357
|
+
.min(1, 'CTA text is required')
|
|
358
|
+
.max(50, 'CTA text must be at most 50 characters'),
|
|
359
|
+
ctaUrl: z
|
|
360
|
+
.string()
|
|
361
|
+
.url('Must be a valid URL')
|
|
362
|
+
.min(1, 'CTA URL is required'),
|
|
363
|
+
effectiveDate: z.string().min(1, 'Effective date is required'),
|
|
364
|
+
expirationDate: z.string().min(1, 'Expiration date is required'),
|
|
365
|
+
// image is optional in schema — uploaded separately via multipart
|
|
366
|
+
})
|
|
367
|
+
.refine(
|
|
368
|
+
(data) => new Date(data.expirationDate) > new Date(data.effectiveDate),
|
|
369
|
+
{
|
|
370
|
+
message: 'Expiration date must be after effective date',
|
|
371
|
+
path: ['expirationDate'],
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
export type HeroSectionFormValues = z.infer<typeof heroSectionSchema>;
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### Form Component Pattern
|
|
379
|
+
|
|
380
|
+
```tsx
|
|
381
|
+
// Example: src/features/heroSection/components/HeroSectionForm.tsx
|
|
382
|
+
import { useForm } from 'react-hook-form';
|
|
383
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
384
|
+
import { Button, Stack, Box } from '@mui/material';
|
|
385
|
+
import { TextFieldController } from '../../../shared/form/TextFieldController';
|
|
386
|
+
import { DatePickerController } from '../../../shared/form/DatePickerController';
|
|
387
|
+
import { ImageUpload } from '../../../shared/components/ImageUpload';
|
|
388
|
+
import { heroSectionSchema, type HeroSectionFormValues } from '../heroSection.schema';
|
|
389
|
+
import type { HeroSection } from '../heroSection.types';
|
|
390
|
+
|
|
391
|
+
interface HeroSectionFormProps {
|
|
392
|
+
defaultValues?: Partial<HeroSectionFormValues>;
|
|
393
|
+
onSubmit: (values: HeroSectionFormValues, imageFile?: File) => void;
|
|
394
|
+
isSubmitting: boolean;
|
|
395
|
+
/** If provided, "Cancel" button navigates back */
|
|
396
|
+
onCancel: () => void;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function HeroSectionForm({
|
|
400
|
+
defaultValues,
|
|
401
|
+
onSubmit,
|
|
402
|
+
isSubmitting,
|
|
403
|
+
onCancel,
|
|
404
|
+
}: HeroSectionFormProps) {
|
|
405
|
+
const { control, handleSubmit } = useForm<HeroSectionFormValues>({
|
|
406
|
+
resolver: zodResolver(heroSectionSchema),
|
|
407
|
+
defaultValues: {
|
|
408
|
+
headline: '',
|
|
409
|
+
subheadline: '',
|
|
410
|
+
ctaText: '',
|
|
411
|
+
ctaUrl: '',
|
|
412
|
+
effectiveDate: '',
|
|
413
|
+
expirationDate: '',
|
|
414
|
+
...defaultValues,
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const [imageFile, setImageFile] = useState<File | undefined>();
|
|
419
|
+
|
|
420
|
+
const handleFormSubmit = (values: HeroSectionFormValues) => {
|
|
421
|
+
onSubmit(values, imageFile);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
return (
|
|
425
|
+
<Box component="form" onSubmit={handleSubmit(handleFormSubmit)} noValidate>
|
|
426
|
+
<Stack spacing={3}>
|
|
427
|
+
<ImageUpload
|
|
428
|
+
label="Hero Image (1600x500 px)"
|
|
429
|
+
onFileChange={setImageFile}
|
|
430
|
+
aspectRatio="1600/500"
|
|
431
|
+
helperText="Image must be exactly 1600×500 pixels"
|
|
432
|
+
/>
|
|
433
|
+
<TextFieldController
|
|
434
|
+
name="headline"
|
|
435
|
+
control={control}
|
|
436
|
+
label="Headline"
|
|
437
|
+
required
|
|
438
|
+
inputProps={{ maxLength: 100 }}
|
|
439
|
+
helperText="Max 100 characters"
|
|
440
|
+
/>
|
|
441
|
+
<TextFieldController
|
|
442
|
+
name="subheadline"
|
|
443
|
+
control={control}
|
|
444
|
+
label="Subheadline"
|
|
445
|
+
required
|
|
446
|
+
multiline
|
|
447
|
+
rows={2}
|
|
448
|
+
inputProps={{ maxLength: 200 }}
|
|
449
|
+
/>
|
|
450
|
+
<TextFieldController
|
|
451
|
+
name="ctaText"
|
|
452
|
+
control={control}
|
|
453
|
+
label="CTA Button Text"
|
|
454
|
+
required
|
|
455
|
+
inputProps={{ maxLength: 50 }}
|
|
456
|
+
/>
|
|
457
|
+
<TextFieldController
|
|
458
|
+
name="ctaUrl"
|
|
459
|
+
control={control}
|
|
460
|
+
label="CTA Button URL"
|
|
461
|
+
required
|
|
462
|
+
placeholder="https://..."
|
|
463
|
+
/>
|
|
464
|
+
<Stack direction="row" spacing={2}>
|
|
465
|
+
<DatePickerController
|
|
466
|
+
name="effectiveDate"
|
|
467
|
+
control={control}
|
|
468
|
+
label="Effective Date"
|
|
469
|
+
required
|
|
470
|
+
/>
|
|
471
|
+
<DatePickerController
|
|
472
|
+
name="expirationDate"
|
|
473
|
+
control={control}
|
|
474
|
+
label="Expiration Date"
|
|
475
|
+
required
|
|
476
|
+
/>
|
|
477
|
+
</Stack>
|
|
478
|
+
|
|
479
|
+
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
|
480
|
+
<Button variant="outlined" onClick={onCancel}>
|
|
481
|
+
Cancel
|
|
482
|
+
</Button>
|
|
483
|
+
<Button type="submit" variant="contained" loading={isSubmitting}>
|
|
484
|
+
{defaultValues ? 'Update' : 'Create'}
|
|
485
|
+
</Button>
|
|
486
|
+
</Stack>
|
|
487
|
+
</Stack>
|
|
488
|
+
</Box>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### RHF Controller Wrappers
|
|
494
|
+
|
|
495
|
+
```tsx
|
|
496
|
+
// src/shared/form/TextFieldController.tsx
|
|
497
|
+
import { Controller, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
|
|
498
|
+
import { TextField, type TextFieldProps } from '@mui/material';
|
|
499
|
+
|
|
500
|
+
type TextFieldControllerProps<T extends FieldValues> = Omit<
|
|
501
|
+
TextFieldProps,
|
|
502
|
+
'name' | 'value' | 'onChange' | 'error' | 'helperText'
|
|
503
|
+
> & {
|
|
504
|
+
name: FieldPath<T>;
|
|
505
|
+
control: Control<T>;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
export function TextFieldController<T extends FieldValues>({
|
|
509
|
+
name,
|
|
510
|
+
control,
|
|
511
|
+
...textFieldProps
|
|
512
|
+
}: TextFieldControllerProps<T>) {
|
|
513
|
+
return (
|
|
514
|
+
<Controller
|
|
515
|
+
name={name}
|
|
516
|
+
control={control}
|
|
517
|
+
render={({ field, fieldState }) => (
|
|
518
|
+
<TextField
|
|
519
|
+
{...field}
|
|
520
|
+
{...textFieldProps}
|
|
521
|
+
error={!!fieldState.error}
|
|
522
|
+
helperText={fieldState.error?.message ?? textFieldProps.helperText}
|
|
523
|
+
fullWidth
|
|
524
|
+
/>
|
|
525
|
+
)}
|
|
526
|
+
/>
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
```tsx
|
|
532
|
+
// src/shared/form/SelectController.tsx
|
|
533
|
+
import { Controller, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
|
|
534
|
+
import {
|
|
535
|
+
FormControl,
|
|
536
|
+
FormHelperText,
|
|
537
|
+
InputLabel,
|
|
538
|
+
MenuItem,
|
|
539
|
+
Select,
|
|
540
|
+
type SelectProps,
|
|
541
|
+
} from '@mui/material';
|
|
542
|
+
|
|
543
|
+
export interface SelectOption {
|
|
544
|
+
value: string | number;
|
|
545
|
+
label: string;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
type SelectControllerProps<T extends FieldValues> = Omit<
|
|
549
|
+
SelectProps,
|
|
550
|
+
'name' | 'value' | 'onChange' | 'error'
|
|
551
|
+
> & {
|
|
552
|
+
name: FieldPath<T>;
|
|
553
|
+
control: Control<T>;
|
|
554
|
+
label: string;
|
|
555
|
+
options: SelectOption[];
|
|
556
|
+
helperText?: string;
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
export function SelectController<T extends FieldValues>({
|
|
560
|
+
name,
|
|
561
|
+
control,
|
|
562
|
+
label,
|
|
563
|
+
options,
|
|
564
|
+
helperText,
|
|
565
|
+
...selectProps
|
|
566
|
+
}: SelectControllerProps<T>) {
|
|
567
|
+
return (
|
|
568
|
+
<Controller
|
|
569
|
+
name={name}
|
|
570
|
+
control={control}
|
|
571
|
+
render={({ field, fieldState }) => (
|
|
572
|
+
<FormControl fullWidth error={!!fieldState.error} size="small">
|
|
573
|
+
<InputLabel>{label}</InputLabel>
|
|
574
|
+
<Select {...field} {...selectProps} label={label}>
|
|
575
|
+
{options.map((opt) => (
|
|
576
|
+
<MenuItem key={opt.value} value={opt.value}>
|
|
577
|
+
{opt.label}
|
|
578
|
+
</MenuItem>
|
|
579
|
+
))}
|
|
580
|
+
</Select>
|
|
581
|
+
<FormHelperText>
|
|
582
|
+
{fieldState.error?.message ?? helperText}
|
|
583
|
+
</FormHelperText>
|
|
584
|
+
</FormControl>
|
|
585
|
+
)}
|
|
586
|
+
/>
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
593
|
+
## Notification Provider
|
|
594
|
+
|
|
595
|
+
```tsx
|
|
596
|
+
// src/shared/components/NotificationProvider.tsx
|
|
597
|
+
import { Alert, Snackbar, Stack } from '@mui/material';
|
|
598
|
+
import { useNotificationStore } from '../../store/notification.store';
|
|
599
|
+
|
|
600
|
+
export function NotificationProvider() {
|
|
601
|
+
const { notifications, dismiss } = useNotificationStore();
|
|
602
|
+
|
|
603
|
+
return (
|
|
604
|
+
<Stack
|
|
605
|
+
spacing={1}
|
|
606
|
+
sx={{ position: 'fixed', bottom: 24, right: 24, zIndex: 9999, maxWidth: 400 }}
|
|
607
|
+
>
|
|
608
|
+
{notifications.map((n) => (
|
|
609
|
+
<Snackbar
|
|
610
|
+
key={n.id}
|
|
611
|
+
open
|
|
612
|
+
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
|
613
|
+
>
|
|
614
|
+
<Alert
|
|
615
|
+
severity={n.type}
|
|
616
|
+
onClose={() => dismiss(n.id)}
|
|
617
|
+
variant="filled"
|
|
618
|
+
elevation={6}
|
|
619
|
+
>
|
|
620
|
+
{n.message}
|
|
621
|
+
</Alert>
|
|
622
|
+
</Snackbar>
|
|
623
|
+
))}
|
|
624
|
+
</Stack>
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
---
|
|
630
|
+
|
|
631
|
+
## Status Chip Component
|
|
632
|
+
|
|
633
|
+
```tsx
|
|
634
|
+
// src/shared/components/StatusChip.tsx
|
|
635
|
+
import { Chip, type ChipProps } from '@mui/material';
|
|
636
|
+
|
|
637
|
+
type StatusColor = 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning';
|
|
638
|
+
|
|
639
|
+
interface StatusConfig {
|
|
640
|
+
label: string;
|
|
641
|
+
color: StatusColor;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
interface StatusChipProps extends Omit<ChipProps, 'color' | 'label'> {
|
|
645
|
+
status: string;
|
|
646
|
+
/** Map of status value → display config. Provide this per-module. */
|
|
647
|
+
statusMap: Record<string, StatusConfig>;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export function StatusChip({ status, statusMap, ...chipProps }: StatusChipProps) {
|
|
651
|
+
const config = statusMap[status] ?? { label: status, color: 'default' as StatusColor };
|
|
652
|
+
|
|
653
|
+
return (
|
|
654
|
+
<Chip
|
|
655
|
+
label={config.label}
|
|
656
|
+
color={config.color}
|
|
657
|
+
size="small"
|
|
658
|
+
{...chipProps}
|
|
659
|
+
/>
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Example usage in a module:
|
|
664
|
+
// const HERO_STATUS_MAP = {
|
|
665
|
+
// DRAFT: { label: 'Draft', color: 'default' },
|
|
666
|
+
// ACTIVE: { label: 'Active', color: 'success' },
|
|
667
|
+
// EXPIRED: { label: 'Expired', color: 'error' },
|
|
668
|
+
// };
|
|
669
|
+
// <StatusChip status={row.status} statusMap={HERO_STATUS_MAP} />
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
## Confirm Dialog Hook
|
|
675
|
+
|
|
676
|
+
```tsx
|
|
677
|
+
// src/shared/components/ConfirmDialog.tsx
|
|
678
|
+
import {
|
|
679
|
+
Button,
|
|
680
|
+
Dialog,
|
|
681
|
+
DialogActions,
|
|
682
|
+
DialogContent,
|
|
683
|
+
DialogContentText,
|
|
684
|
+
DialogTitle,
|
|
685
|
+
} from '@mui/material';
|
|
686
|
+
import { create } from 'zustand';
|
|
687
|
+
|
|
688
|
+
interface ConfirmState {
|
|
689
|
+
open: boolean;
|
|
690
|
+
title: string;
|
|
691
|
+
message: string;
|
|
692
|
+
confirmLabel: string;
|
|
693
|
+
cancelLabel: string;
|
|
694
|
+
onConfirm: (() => void) | null;
|
|
695
|
+
show: (options: Omit<ConfirmState, 'open' | 'show' | 'hide'>) => void;
|
|
696
|
+
hide: () => void;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const useConfirmStore = create<ConfirmState>((set) => ({
|
|
700
|
+
open: false,
|
|
701
|
+
title: '',
|
|
702
|
+
message: '',
|
|
703
|
+
confirmLabel: 'Confirm',
|
|
704
|
+
cancelLabel: 'Cancel',
|
|
705
|
+
onConfirm: null,
|
|
706
|
+
show: (options) => set({ open: true, ...options }),
|
|
707
|
+
hide: () => set({ open: false, onConfirm: null }),
|
|
708
|
+
}));
|
|
709
|
+
|
|
710
|
+
/** Hook to imperatively trigger a confirmation dialog */
|
|
711
|
+
export function useConfirm() {
|
|
712
|
+
const { show } = useConfirmStore();
|
|
713
|
+
|
|
714
|
+
return (options: {
|
|
715
|
+
title?: string;
|
|
716
|
+
message: string;
|
|
717
|
+
confirmLabel?: string;
|
|
718
|
+
cancelLabel?: string;
|
|
719
|
+
}): Promise<boolean> =>
|
|
720
|
+
new Promise((resolve) => {
|
|
721
|
+
show({
|
|
722
|
+
title: options.title ?? 'Confirm Action',
|
|
723
|
+
message: options.message,
|
|
724
|
+
confirmLabel: options.confirmLabel ?? 'Confirm',
|
|
725
|
+
cancelLabel: options.cancelLabel ?? 'Cancel',
|
|
726
|
+
onConfirm: () => resolve(true),
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Render this once at the app root to display the dialog */
|
|
732
|
+
export function ConfirmDialog() {
|
|
733
|
+
const { open, title, message, confirmLabel, cancelLabel, onConfirm, hide } =
|
|
734
|
+
useConfirmStore();
|
|
735
|
+
|
|
736
|
+
const handleConfirm = () => {
|
|
737
|
+
onConfirm?.();
|
|
738
|
+
hide();
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
return (
|
|
742
|
+
<Dialog open={open} onClose={hide} maxWidth="xs" fullWidth>
|
|
743
|
+
<DialogTitle>{title}</DialogTitle>
|
|
744
|
+
<DialogContent>
|
|
745
|
+
<DialogContentText>{message}</DialogContentText>
|
|
746
|
+
</DialogContent>
|
|
747
|
+
<DialogActions>
|
|
748
|
+
<Button onClick={hide} color="inherit">
|
|
749
|
+
{cancelLabel}
|
|
750
|
+
</Button>
|
|
751
|
+
<Button onClick={handleConfirm} color="error" variant="contained" autoFocus>
|
|
752
|
+
{confirmLabel}
|
|
753
|
+
</Button>
|
|
754
|
+
</DialogActions>
|
|
755
|
+
</Dialog>
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
---
|
|
761
|
+
|
|
762
|
+
## API Function Pattern
|
|
763
|
+
|
|
764
|
+
```typescript
|
|
765
|
+
// Example: src/features/heroSection/api/heroSectionApi.ts
|
|
766
|
+
import { axiosInstance } from '../../../lib/api/axiosInstance';
|
|
767
|
+
import type {
|
|
768
|
+
HeroSection,
|
|
769
|
+
HeroSectionCreate,
|
|
770
|
+
HeroSectionUpdate,
|
|
771
|
+
HeroSectionListParams,
|
|
772
|
+
PaginatedResponse,
|
|
773
|
+
} from '../heroSection.types';
|
|
774
|
+
|
|
775
|
+
export const heroSectionApi = {
|
|
776
|
+
/**
|
|
777
|
+
* Fetch paginated list of hero sections.
|
|
778
|
+
*/
|
|
779
|
+
list: async (params: HeroSectionListParams): Promise<PaginatedResponse<HeroSection>> => {
|
|
780
|
+
const { data } = await axiosInstance.get<PaginatedResponse<HeroSection>>(
|
|
781
|
+
'/hero-sections',
|
|
782
|
+
{ params },
|
|
783
|
+
);
|
|
784
|
+
return data;
|
|
785
|
+
},
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Fetch a single hero section by ID.
|
|
789
|
+
*/
|
|
790
|
+
getById: async (id: string): Promise<HeroSection> => {
|
|
791
|
+
const { data } = await axiosInstance.get<HeroSection>(`/hero-sections/${id}`);
|
|
792
|
+
return data;
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Create a new hero section with an optional image upload.
|
|
797
|
+
* Uses multipart/form-data when an image file is provided.
|
|
798
|
+
*/
|
|
799
|
+
create: async (payload: HeroSectionCreate, imageFile?: File): Promise<HeroSection> => {
|
|
800
|
+
if (imageFile) {
|
|
801
|
+
const formData = new FormData();
|
|
802
|
+
formData.append('data', JSON.stringify(payload));
|
|
803
|
+
formData.append('image', imageFile);
|
|
804
|
+
const { data } = await axiosInstance.post<HeroSection>('/hero-sections', formData, {
|
|
805
|
+
headers: { 'Content-Type': 'multipart/form-data' },
|
|
806
|
+
});
|
|
807
|
+
return data;
|
|
808
|
+
}
|
|
809
|
+
const { data } = await axiosInstance.post<HeroSection>('/hero-sections', payload);
|
|
810
|
+
return data;
|
|
811
|
+
},
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Update an existing hero section.
|
|
815
|
+
*/
|
|
816
|
+
update: async (
|
|
817
|
+
id: string,
|
|
818
|
+
payload: HeroSectionUpdate,
|
|
819
|
+
imageFile?: File,
|
|
820
|
+
): Promise<HeroSection> => {
|
|
821
|
+
if (imageFile) {
|
|
822
|
+
const formData = new FormData();
|
|
823
|
+
formData.append('data', JSON.stringify(payload));
|
|
824
|
+
formData.append('image', imageFile);
|
|
825
|
+
const { data } = await axiosInstance.put<HeroSection>(
|
|
826
|
+
`/hero-sections/${id}`,
|
|
827
|
+
formData,
|
|
828
|
+
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
|
829
|
+
);
|
|
830
|
+
return data;
|
|
831
|
+
}
|
|
832
|
+
const { data } = await axiosInstance.put<HeroSection>(`/hero-sections/${id}`, payload);
|
|
833
|
+
return data;
|
|
834
|
+
},
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Delete a hero section by ID.
|
|
838
|
+
*/
|
|
839
|
+
delete: async (id: string): Promise<void> => {
|
|
840
|
+
await axiosInstance.delete(`/hero-sections/${id}`);
|
|
841
|
+
},
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Serve image by ID (returns a URL for use in <img src="..."> via Blob URL or direct endpoint).
|
|
845
|
+
*/
|
|
846
|
+
imageUrl: (id: string): string =>
|
|
847
|
+
`${import.meta.env.VITE_API_BASE_URL}/hero-sections/${id}/image`,
|
|
848
|
+
};
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
---
|
|
852
|
+
|
|
853
|
+
## TypeScript Types Pattern
|
|
854
|
+
|
|
855
|
+
```typescript
|
|
856
|
+
// Example: src/features/heroSection/heroSection.types.ts
|
|
857
|
+
|
|
858
|
+
/** Enum derived from PRD.md constraint for hero section status */
|
|
859
|
+
export type HeroSectionStatus = 'DRAFT' | 'ACTIVE' | 'EXPIRED';
|
|
860
|
+
|
|
861
|
+
/** Full hero section entity returned by the API */
|
|
862
|
+
export interface HeroSection {
|
|
863
|
+
id: string;
|
|
864
|
+
headline: string;
|
|
865
|
+
subheadline: string;
|
|
866
|
+
ctaText: string;
|
|
867
|
+
ctaUrl: string;
|
|
868
|
+
effectiveDate: string; // ISO date string (YYYY-MM-DD)
|
|
869
|
+
expirationDate: string; // ISO date string
|
|
870
|
+
status: HeroSectionStatus;
|
|
871
|
+
createdAt: string; // ISO datetime string
|
|
872
|
+
updatedAt: string;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Payload for creating a hero section (image uploaded separately) */
|
|
876
|
+
export interface HeroSectionCreate {
|
|
877
|
+
headline: string;
|
|
878
|
+
subheadline: string;
|
|
879
|
+
ctaText: string;
|
|
880
|
+
ctaUrl: string;
|
|
881
|
+
effectiveDate: string;
|
|
882
|
+
expirationDate: string;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** Payload for updating a hero section */
|
|
886
|
+
export type HeroSectionUpdate = Partial<HeroSectionCreate>;
|
|
887
|
+
|
|
888
|
+
/** Query parameters for the list endpoint */
|
|
889
|
+
export interface HeroSectionListParams {
|
|
890
|
+
page?: number;
|
|
891
|
+
size?: number;
|
|
892
|
+
status?: HeroSectionStatus | '';
|
|
893
|
+
effectiveDateFrom?: string;
|
|
894
|
+
effectiveDateTo?: string;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** Generic paginated response wrapper — matches the backend API contract */
|
|
898
|
+
export interface PaginatedResponse<T> {
|
|
899
|
+
content: T[];
|
|
900
|
+
page: number;
|
|
901
|
+
size: number;
|
|
902
|
+
totalElements: number;
|
|
903
|
+
totalPages: number;
|
|
904
|
+
}
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
---
|
|
908
|
+
|
|
909
|
+
## Page Component Pattern
|
|
910
|
+
|
|
911
|
+
```tsx
|
|
912
|
+
// Example: src/features/heroSection/pages/HeroSectionListPage.tsx
|
|
913
|
+
import { useState } from 'react';
|
|
914
|
+
import { useNavigate } from 'react-router-dom';
|
|
915
|
+
import {
|
|
916
|
+
Box,
|
|
917
|
+
Button,
|
|
918
|
+
Card,
|
|
919
|
+
Chip,
|
|
920
|
+
IconButton,
|
|
921
|
+
MenuItem,
|
|
922
|
+
Select,
|
|
923
|
+
Stack,
|
|
924
|
+
Table,
|
|
925
|
+
TableBody,
|
|
926
|
+
TableCell,
|
|
927
|
+
TableContainer,
|
|
928
|
+
TableHead,
|
|
929
|
+
TablePagination,
|
|
930
|
+
TableRow,
|
|
931
|
+
Tooltip,
|
|
932
|
+
} from '@mui/material';
|
|
933
|
+
import AddIcon from '@mui/icons-material/Add';
|
|
934
|
+
import EditIcon from '@mui/icons-material/Edit';
|
|
935
|
+
import DeleteIcon from '@mui/icons-material/Delete';
|
|
936
|
+
import { PageHeader } from '../../../shared/components/PageHeader';
|
|
937
|
+
import { StatusChip } from '../../../shared/components/StatusChip';
|
|
938
|
+
import { LoadingOverlay } from '../../../shared/components/LoadingOverlay';
|
|
939
|
+
import { EmptyState } from '../../../shared/components/EmptyState';
|
|
940
|
+
import { useConfirm } from '../../../shared/components/ConfirmDialog';
|
|
941
|
+
import { useHeroSections } from '../hooks/useHeroSections';
|
|
942
|
+
import { useDeleteHeroSection } from '../hooks/useHeroSectionMutations';
|
|
943
|
+
import type { HeroSectionListParams, HeroSectionStatus } from '../heroSection.types';
|
|
944
|
+
|
|
945
|
+
const HERO_STATUS_MAP = {
|
|
946
|
+
DRAFT: { label: 'Draft', color: 'default' as const },
|
|
947
|
+
ACTIVE: { label: 'Active', color: 'success' as const },
|
|
948
|
+
EXPIRED: { label: 'Expired', color: 'error' as const },
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
export function HeroSectionListPage() {
|
|
952
|
+
const navigate = useNavigate();
|
|
953
|
+
const confirm = useConfirm();
|
|
954
|
+
|
|
955
|
+
const [params, setParams] = useState<HeroSectionListParams>({
|
|
956
|
+
page: 0,
|
|
957
|
+
size: 10,
|
|
958
|
+
status: '',
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
const { data, isLoading, isError } = useHeroSections(params);
|
|
962
|
+
const deleteMutation = useDeleteHeroSection();
|
|
963
|
+
|
|
964
|
+
const handleDelete = async (id: string, headline: string) => {
|
|
965
|
+
const confirmed = await confirm({
|
|
966
|
+
title: 'Delete Hero Section',
|
|
967
|
+
message: `Are you sure you want to delete "${headline}"? This action cannot be undone.`,
|
|
968
|
+
confirmLabel: 'Delete',
|
|
969
|
+
});
|
|
970
|
+
if (confirmed) {
|
|
971
|
+
deleteMutation.mutate(id);
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
if (isLoading) return <LoadingOverlay />;
|
|
976
|
+
if (isError) return <div>Failed to load hero sections.</div>;
|
|
977
|
+
|
|
978
|
+
return (
|
|
979
|
+
<Box>
|
|
980
|
+
<PageHeader
|
|
981
|
+
title="Hero Sections"
|
|
982
|
+
breadcrumbs={[{ label: 'Dashboard', path: '/dashboard' }, { label: 'Hero Sections' }]}
|
|
983
|
+
actions={
|
|
984
|
+
<Button
|
|
985
|
+
variant="contained"
|
|
986
|
+
startIcon={<AddIcon />}
|
|
987
|
+
onClick={() => navigate('/hero-section/create')}
|
|
988
|
+
>
|
|
989
|
+
Add Hero Section
|
|
990
|
+
</Button>
|
|
991
|
+
}
|
|
992
|
+
/>
|
|
993
|
+
|
|
994
|
+
{/* Filters */}
|
|
995
|
+
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
|
996
|
+
<Select
|
|
997
|
+
size="small"
|
|
998
|
+
value={params.status ?? ''}
|
|
999
|
+
onChange={(e) => setParams((p) => ({ ...p, status: e.target.value as HeroSectionStatus | '', page: 0 }))}
|
|
1000
|
+
displayEmpty
|
|
1001
|
+
sx={{ minWidth: 160 }}
|
|
1002
|
+
>
|
|
1003
|
+
<MenuItem value="">All Statuses</MenuItem>
|
|
1004
|
+
<MenuItem value="DRAFT">Draft</MenuItem>
|
|
1005
|
+
<MenuItem value="ACTIVE">Active</MenuItem>
|
|
1006
|
+
<MenuItem value="EXPIRED">Expired</MenuItem>
|
|
1007
|
+
</Select>
|
|
1008
|
+
</Stack>
|
|
1009
|
+
|
|
1010
|
+
<Card>
|
|
1011
|
+
<TableContainer>
|
|
1012
|
+
<Table>
|
|
1013
|
+
<TableHead>
|
|
1014
|
+
<TableRow>
|
|
1015
|
+
<TableCell>Headline</TableCell>
|
|
1016
|
+
<TableCell>Effective Date</TableCell>
|
|
1017
|
+
<TableCell>Expiration Date</TableCell>
|
|
1018
|
+
<TableCell>Status</TableCell>
|
|
1019
|
+
<TableCell align="right">Actions</TableCell>
|
|
1020
|
+
</TableRow>
|
|
1021
|
+
</TableHead>
|
|
1022
|
+
<TableBody>
|
|
1023
|
+
{data?.content.length === 0 ? (
|
|
1024
|
+
<TableRow>
|
|
1025
|
+
<TableCell colSpan={5}>
|
|
1026
|
+
<EmptyState message="No hero sections found." />
|
|
1027
|
+
</TableCell>
|
|
1028
|
+
</TableRow>
|
|
1029
|
+
) : (
|
|
1030
|
+
data?.content.map((row) => (
|
|
1031
|
+
<TableRow key={row.id} hover>
|
|
1032
|
+
<TableCell>{row.headline}</TableCell>
|
|
1033
|
+
<TableCell>{row.effectiveDate}</TableCell>
|
|
1034
|
+
<TableCell>{row.expirationDate}</TableCell>
|
|
1035
|
+
<TableCell>
|
|
1036
|
+
<StatusChip status={row.status} statusMap={HERO_STATUS_MAP} />
|
|
1037
|
+
</TableCell>
|
|
1038
|
+
<TableCell align="right">
|
|
1039
|
+
<Tooltip title="Edit">
|
|
1040
|
+
<IconButton
|
|
1041
|
+
size="small"
|
|
1042
|
+
onClick={() => navigate(`/hero-section/${row.id}/edit`)}
|
|
1043
|
+
>
|
|
1044
|
+
<EditIcon fontSize="small" />
|
|
1045
|
+
</IconButton>
|
|
1046
|
+
</Tooltip>
|
|
1047
|
+
<Tooltip title="Delete">
|
|
1048
|
+
<IconButton
|
|
1049
|
+
size="small"
|
|
1050
|
+
color="error"
|
|
1051
|
+
onClick={() => handleDelete(row.id, row.headline)}
|
|
1052
|
+
>
|
|
1053
|
+
<DeleteIcon fontSize="small" />
|
|
1054
|
+
</IconButton>
|
|
1055
|
+
</Tooltip>
|
|
1056
|
+
</TableCell>
|
|
1057
|
+
</TableRow>
|
|
1058
|
+
))
|
|
1059
|
+
)}
|
|
1060
|
+
</TableBody>
|
|
1061
|
+
</Table>
|
|
1062
|
+
</TableContainer>
|
|
1063
|
+
<TablePagination
|
|
1064
|
+
component="div"
|
|
1065
|
+
count={data?.totalElements ?? 0}
|
|
1066
|
+
page={params.page ?? 0}
|
|
1067
|
+
rowsPerPage={params.size ?? 10}
|
|
1068
|
+
onPageChange={(_, page) => setParams((p) => ({ ...p, page }))}
|
|
1069
|
+
onRowsPerPageChange={(e) =>
|
|
1070
|
+
setParams((p) => ({ ...p, size: parseInt(e.target.value), page: 0 }))
|
|
1071
|
+
}
|
|
1072
|
+
/>
|
|
1073
|
+
</Card>
|
|
1074
|
+
</Box>
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
```
|