App reorganization
This commit is contained in:
@@ -1,381 +0,0 @@
|
||||
# Custom Hooks Documentation
|
||||
|
||||
This document provides comprehensive documentation for all custom hooks available in the project.
|
||||
|
||||
## Overview
|
||||
|
||||
Custom hooks encapsulate reusable logic and patterns across components, improving code organization, maintainability, and consistency.
|
||||
|
||||
## Available Hooks
|
||||
|
||||
### `useClickOutside`
|
||||
|
||||
Detects clicks outside of specified elements. Useful for closing dropdowns, modals, or menus.
|
||||
|
||||
**Location:** `app/hooks/useClickOutside.ts`
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { useClickOutside } from "../hooks";
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useClickOutside([menuRef, buttonRef], () => setIsOpen(false), isOpen);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `refs`: Array of refs to elements that should not trigger the callback
|
||||
- `handler`: Callback function to execute when clicking outside
|
||||
- `enabled`: Whether the hook is enabled (default: true)
|
||||
|
||||
**Example:** Used in `Select.tsx` for closing dropdown menus
|
||||
|
||||
---
|
||||
|
||||
### `useAnalytics`
|
||||
|
||||
Centralized analytics tracking for component interactions. Supports both Google Analytics (gtag) and custom callbacks.
|
||||
|
||||
**Location:** `app/hooks/useAnalytics.ts`
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { useAnalytics } from "../hooks";
|
||||
|
||||
const { trackEvent, trackCustomEvent } = useAnalytics();
|
||||
|
||||
// Standard event tracking
|
||||
trackEvent({
|
||||
event: "button_click",
|
||||
category: "engagement",
|
||||
label: "contact_button",
|
||||
component: "AskOrganizer",
|
||||
});
|
||||
|
||||
// Custom event with callback
|
||||
trackCustomEvent(
|
||||
"contact_button_click",
|
||||
{
|
||||
component: "AskOrganizer",
|
||||
variant: "centered",
|
||||
},
|
||||
onContactClick, // Optional callback
|
||||
);
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
|
||||
- `trackEvent`: Function to track standard analytics events
|
||||
- `trackCustomEvent`: Function to track custom events with optional callback
|
||||
|
||||
**Example:** Used in `AskOrganizer.tsx` for tracking button clicks
|
||||
|
||||
---
|
||||
|
||||
### `useComponentId`
|
||||
|
||||
Generates unique component IDs for accessibility. Provides consistent ID generation pattern.
|
||||
|
||||
**Location:** `app/hooks/useComponentId.ts`
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { useComponentId } from "../hooks";
|
||||
|
||||
const { id, labelId } = useComponentId("input", props.id);
|
||||
// id: "input-123" or props.id if provided
|
||||
// labelId: "input-123-label"
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `prefix`: Prefix for the generated ID (e.g., "input", "select")
|
||||
- `providedId`: Optional ID provided via props (takes precedence)
|
||||
|
||||
**Returns:**
|
||||
|
||||
- `id`: Component ID
|
||||
- `labelId`: Associated label ID for accessibility
|
||||
|
||||
**Example:** Used in `Input.tsx`, `TextArea.tsx`, `Checkbox.tsx`
|
||||
|
||||
---
|
||||
|
||||
### `useFormField`
|
||||
|
||||
Manages form field event handlers with disabled state handling. Ensures handlers respect disabled state.
|
||||
|
||||
**Location:** `app/hooks/useFormField.ts`
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { useFormField } from "../hooks";
|
||||
|
||||
const { handleChange, handleFocus, handleBlur } = useFormField(disabled, {
|
||||
onChange: (e) => setValue(e.target.value),
|
||||
onFocus: (e) => setFocused(true),
|
||||
onBlur: (e) => setFocused(false),
|
||||
});
|
||||
|
||||
// Use in component
|
||||
<input onChange={handleChange} onFocus={handleFocus} onBlur={handleBlur} />;
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `disabled`: Whether the field is disabled
|
||||
- `handlers`: Object containing onChange, onFocus, onBlur handlers
|
||||
|
||||
**Returns:**
|
||||
|
||||
- `handleChange`: Wrapped onChange handler
|
||||
- `handleFocus`: Wrapped onFocus handler
|
||||
- `handleBlur`: Wrapped onBlur handler
|
||||
|
||||
**Example:** Used in `Input.tsx`, `TextArea.tsx`
|
||||
|
||||
---
|
||||
|
||||
### `useComponentStyles`
|
||||
|
||||
Manages component size and state styles. Provides a consistent pattern for styling components.
|
||||
|
||||
**Location:** `app/hooks/useComponentStyles.ts`
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { useComponentStyles } from "../hooks";
|
||||
|
||||
const sizeStyles = {
|
||||
small: { input: "h-8 text-xs", label: "text-xs" },
|
||||
medium: { input: "h-10 text-sm", label: "text-sm" },
|
||||
};
|
||||
|
||||
const stateStyles = {
|
||||
default: { input: "border-gray-300", label: "text-gray-700" },
|
||||
focus: { input: "border-blue-500", label: "text-gray-700" },
|
||||
};
|
||||
|
||||
const { sizeClasses, stateClasses } = useComponentStyles({
|
||||
size: "medium",
|
||||
state: "default",
|
||||
disabled: false,
|
||||
error: false,
|
||||
sizeStyles,
|
||||
stateStyles,
|
||||
});
|
||||
```
|
||||
|
||||
**Note:** This hook is available but styling logic is often component-specific. Consider using it when you have multiple components with similar styling patterns.
|
||||
|
||||
---
|
||||
|
||||
### `useSchemaData`
|
||||
|
||||
Generates Schema.org structured data (JSON-LD) for SEO and search engines.
|
||||
|
||||
**Location:** `app/hooks/useSchemaData.ts`
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { useSchemaData } from "../hooks";
|
||||
|
||||
// HowTo schema
|
||||
const schemaData = useSchemaData({
|
||||
type: "HowTo",
|
||||
name: "How to build a community",
|
||||
description: "Step-by-step guide",
|
||||
steps: [
|
||||
{ name: "Step 1", text: "Start here" },
|
||||
{ name: "Step 2", text: "Continue here" },
|
||||
],
|
||||
});
|
||||
|
||||
// Organization schema
|
||||
const orgSchema = useSchemaData({
|
||||
type: "Organization",
|
||||
name: "Media Economies Design Lab",
|
||||
url: "https://communityrule.com",
|
||||
email: "medlab@colorado.edu",
|
||||
sameAs: ["https://twitter.com/medlab"],
|
||||
});
|
||||
|
||||
// Render in component
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
|
||||
/>;
|
||||
```
|
||||
|
||||
**Supported Types:**
|
||||
|
||||
- `Organization` - Organization information
|
||||
- `WebSite` - Website navigation and search
|
||||
- `HowTo` - Step-by-step instructions
|
||||
- `Article` - Blog posts and articles
|
||||
- `BreadcrumbList` - Navigation breadcrumbs
|
||||
|
||||
**Example:** Used in `NumberedCards.tsx`, `Header.tsx`, `Footer.tsx`
|
||||
|
||||
---
|
||||
|
||||
### `useMediaQuery`
|
||||
|
||||
Responsive breakpoint detection using window.matchMedia.
|
||||
|
||||
**Location:** `app/hooks/useMediaQuery.ts`
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { useMediaQuery, useIsMobile, useIsDesktop } from "../hooks";
|
||||
|
||||
// Using breakpoint key
|
||||
const isMobile = useMediaQuery("lg", "max");
|
||||
// Returns true if screen width < 1024px
|
||||
|
||||
// Using custom query
|
||||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||
|
||||
// Convenience hooks
|
||||
const isMobile = useIsMobile(); // Below lg breakpoint
|
||||
const isDesktop = useIsDesktop(); // lg breakpoint and above
|
||||
```
|
||||
|
||||
**Available Breakpoints:**
|
||||
|
||||
- `sm`: 640px
|
||||
- `md`: 768px
|
||||
- `lg`: 1024px
|
||||
- `xl`: 1280px
|
||||
- `2xl`: 1536px
|
||||
|
||||
**Example:** Used in `RelatedArticles.tsx` for responsive behavior
|
||||
|
||||
---
|
||||
|
||||
### `useFormValidation`
|
||||
|
||||
Form validation with field-level error handling.
|
||||
|
||||
**Location:** `app/hooks/useFormValidation.ts`
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { useFormValidation, validationRules } from "../hooks";
|
||||
|
||||
const {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
validate,
|
||||
isValid,
|
||||
reset,
|
||||
} = useFormValidation({
|
||||
initialValues: { email: "", password: "" },
|
||||
validationRules: {
|
||||
email: [validationRules.required, validationRules.email],
|
||||
password: [validationRules.required, validationRules.minLength(8)],
|
||||
},
|
||||
validateOnChange: true,
|
||||
validateOnBlur: true,
|
||||
});
|
||||
|
||||
// In component
|
||||
<input
|
||||
name="email"
|
||||
value={values.email}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
/>;
|
||||
{
|
||||
errors.email && touched.email && <span>{errors.email}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
**Available Validation Rules:**
|
||||
|
||||
- `validationRules.required` - Field is required
|
||||
- `validationRules.email` - Valid email format
|
||||
- `validationRules.minLength(n)` - Minimum length
|
||||
- `validationRules.maxLength(n)` - Maximum length
|
||||
- `validationRules.pattern(regex, message)` - Custom regex pattern
|
||||
|
||||
**Returns:**
|
||||
|
||||
- `values` - Current form values
|
||||
- `errors` - Field error messages
|
||||
- `touched` - Fields that have been interacted with
|
||||
- `handleChange` - Change handler
|
||||
- `handleBlur` - Blur handler
|
||||
- `validate` - Manual validation function
|
||||
- `isValid` - Boolean indicating if form is valid
|
||||
- `reset` - Reset form to initial values
|
||||
- `setValue` - Programmatically set field value
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Import from index:** Always import hooks from `app/hooks` index file:
|
||||
|
||||
```tsx
|
||||
import { useAnalytics, useComponentId } from "../hooks";
|
||||
```
|
||||
|
||||
2. **TypeScript:** All hooks are fully typed. Use TypeScript for better IDE support.
|
||||
|
||||
3. **Testing:** Hooks should be tested independently. See `tests/unit/hooks/` for examples.
|
||||
|
||||
4. **Documentation:** When creating new hooks, add JSDoc comments and update this documentation.
|
||||
|
||||
5. **Performance:** Hooks use `useMemo` and `useCallback` where appropriate to prevent unnecessary re-renders.
|
||||
|
||||
## Refactored Components
|
||||
|
||||
The following components have been refactored to use custom hooks:
|
||||
|
||||
- **Select** - Uses `useClickOutside` (now uses Container/Presentation pattern)
|
||||
- **AskOrganizer** - Uses `useAnalytics` (now uses Container/Presentation pattern)
|
||||
- **Input.tsx** - Uses `useComponentId` and `useFormField`
|
||||
- **TextArea.tsx** - Uses `useComponentId` and `useFormField`
|
||||
- **Checkbox.tsx** - Uses `useComponentId`
|
||||
- **NumberedCards** - Uses `useSchemaData` (now uses Container/Presentation pattern)
|
||||
- **RelatedArticles.tsx** - Uses `useIsMobile`
|
||||
|
||||
> **Note**: Components marked with "Container/Presentation pattern" have been refactored to separate logic (container) from presentation (view). Hooks are used in the container components. See [Container/Presentation Pattern Guide](./guides/container-presentation-pattern.md) for details.
|
||||
|
||||
## Adding New Hooks
|
||||
|
||||
When creating a new hook:
|
||||
|
||||
1. Create the hook file in `app/hooks/`
|
||||
2. Export it from `app/hooks/index.ts`
|
||||
3. Add JSDoc comments with examples
|
||||
4. Write unit tests in `tests/unit/hooks/`
|
||||
5. Update this documentation
|
||||
6. Refactor at least one component to use it as a proof of concept
|
||||
|
||||
## Testing
|
||||
|
||||
All hooks have unit tests in `tests/unit/hooks/`. Run tests with:
|
||||
|
||||
```bash
|
||||
npm test -- tests/unit/hooks
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [React Hooks Documentation](https://react.dev/reference/react) - Official React hooks documentation
|
||||
+29
-64
@@ -1,76 +1,41 @@
|
||||
# Documentation
|
||||
|
||||
This directory contains project documentation organized by topic.
|
||||
User-facing docs. Implementation conventions live in `.cursor/rules/`.
|
||||
|
||||
## Documentation Structure
|
||||
## Canonical references
|
||||
|
||||
### Core Documentation
|
||||
- [create-flow.md](./create-flow.md) — Custom create-rule wizard: stages,
|
||||
URLs, persistence. Source of truth for product/eng alignment.
|
||||
- [testing-guide.md](./testing-guide.md) — Testing philosophy and what to
|
||||
cover at each layer.
|
||||
|
||||
- **[TESTING_GUIDE.md](./TESTING_GUIDE.md)** - Complete testing guide covering component tests, E2E tests, accessibility, and testing philosophy
|
||||
- **[CUSTOM_HOOKS.md](./CUSTOM_HOOKS.md)** - Documentation for all custom React hooks used in the project
|
||||
## Author guides (`guides/`)
|
||||
|
||||
### Guides (`guides/`)
|
||||
- [content-creation.md](./guides/content-creation.md) — Writing and
|
||||
publishing blog posts.
|
||||
- [i18n-translation-workflow.md](./guides/i18n-translation-workflow.md) —
|
||||
Editing UI copy and translation bundles.
|
||||
|
||||
- **[container-presentation-pattern.md](./guides/container-presentation-pattern.md)** - Container/Presentation pattern guide for component architecture
|
||||
- **[content-creation.md](./guides/content-creation.md)** - Guide for creating blog content
|
||||
- **[i18n-translation-workflow.md](./guides/i18n-translation-workflow.md)** - Guide for working with translations and UI content management
|
||||
## Temporary backend planning
|
||||
|
||||
## Quick Navigation
|
||||
These will be deleted once the backend services are stood up:
|
||||
|
||||
### For Testing
|
||||
- [guides/backend-roadmap.md](./guides/backend-roadmap.md)
|
||||
- [guides/backend-linear-tickets.md](./guides/backend-linear-tickets.md)
|
||||
- [guides/template-recommendation-matrix.md](./guides/template-recommendation-matrix.md)
|
||||
|
||||
Start with **[TESTING_GUIDE.md](./TESTING_GUIDE.md)** for:
|
||||
## Cursor rules
|
||||
|
||||
- Testing philosophy and approach
|
||||
- Component testing with `componentTestSuite`
|
||||
- E2E testing with Playwright
|
||||
- Accessibility testing
|
||||
- How to add tests for new components
|
||||
Implementation contracts are enforced by `.cursor/rules/*.mdc`:
|
||||
|
||||
### For Custom Hooks
|
||||
|
||||
See **[CUSTOM_HOOKS.md](./CUSTOM_HOOKS.md)** for:
|
||||
|
||||
- Available custom hooks
|
||||
- Usage examples
|
||||
- API documentation
|
||||
|
||||
### For Component Architecture
|
||||
|
||||
See **[container-presentation-pattern.md](./guides/container-presentation-pattern.md)** for:
|
||||
|
||||
- Container/Presentation pattern overview
|
||||
- When and how to use the pattern
|
||||
- Migration guide for existing components
|
||||
- Best practices and examples
|
||||
|
||||
### For Content Creation
|
||||
|
||||
See **[content-creation.md](./guides/content-creation.md)** for:
|
||||
|
||||
- How to create blog articles
|
||||
- Content guidelines
|
||||
- Contribution workflow
|
||||
|
||||
### For Translations and UI Content
|
||||
|
||||
See **[i18n-translation-workflow.md](./guides/i18n-translation-workflow.md)** for:
|
||||
|
||||
- How to update UI text content
|
||||
- Translation key structure and naming conventions
|
||||
- How to extract strings from components
|
||||
- How to add new translation keys
|
||||
- Best practices for content creators and developers
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **Vitest Documentation**: https://vitest.dev/
|
||||
- **Playwright Documentation**: https://playwright.dev/
|
||||
- **React Testing Library**: https://testing-library.com/
|
||||
- **Lighthouse CI**: https://github.com/GoogleChrome/lighthouse-ci
|
||||
- **Next.js Documentation**: https://nextjs.org/docs
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 2025
|
||||
**Maintained by**: CommunityRule Development Team
|
||||
| Rule | Scope |
|
||||
| --- | --- |
|
||||
| `component-structure.mdc` | 4-file split (container/view/types/index). |
|
||||
| `component-props.mdc` | Lowercase enum prop convention + Figma traceability. |
|
||||
| `tailwind-styling.mdc` | Token usage and class composition. |
|
||||
| `localization.mdc` | `messages/` bundles and `useMessages()`. |
|
||||
| `testing.mdc` | Test layout, helpers, required imports. |
|
||||
| `storybook.mdc` | Story location and `argTypes` patterns. |
|
||||
| `hooks.mdc` | Custom hook authoring + TSDoc as the API reference. |
|
||||
| `create-flow.mdc` | Wizard step / screen registry conventions. |
|
||||
| `api-routes.mdc` | API route handler conventions. |
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
## Testing Guide
|
||||
|
||||
### Philosophy
|
||||
|
||||
- **Test behaviour, not implementation**: Focus on what the user can see and do, not internal details.
|
||||
- **Single source of truth per component**: Each component should have **one consolidated test file**.
|
||||
- **Accessibility is mandatory**: Basic a11y checks run as part of every component suite.
|
||||
- **E2E is sparse**: Only cover critical user journeys that span pages or systems.
|
||||
|
||||
### Test Structure
|
||||
|
||||
The test directory structure is organized as follows:
|
||||
|
||||
```text
|
||||
tests/
|
||||
components/ # All component-focused tests (Vitest + RTL)
|
||||
Button.test.tsx
|
||||
Input.test.tsx
|
||||
Checkbox.test.tsx
|
||||
Select.test.tsx
|
||||
Switch.test.tsx
|
||||
pages/ # Page-level tests (home, blog, etc.)
|
||||
home.test.jsx
|
||||
blog.test.jsx
|
||||
e2e/ # True end‑to‑end flows + visual regression (Playwright)
|
||||
critical-journeys.spec.ts # Main user journeys (homepage, navigation, interactions)
|
||||
visual-regression.spec.ts # Critical page screenshots only (5 tests)
|
||||
edge-cases.spec.ts # Critical error scenarios (4 tests)
|
||||
performance.spec.ts # Essential performance checks (2 tests)
|
||||
utils/ # Shared test utilities
|
||||
componentTestSuite.tsx
|
||||
msw/ # MSW server setup for mocking
|
||||
server.ts
|
||||
accessibility/
|
||||
e2e/ # E2E accessibility checks (WCAG compliance)
|
||||
wcag-compliance.spec.ts
|
||||
```
|
||||
|
||||
**Component tests** (`tests/components/`) use the standard `componentTestSuite` utility to ensure consistent baseline coverage for all UI components. **Page tests** (`tests/pages/`) cover page-level rendering and flows. **E2E tests** (`tests/e2e/`) focus on critical user journeys, visual regression, and performance. **Accessibility E2E** (`tests/accessibility/e2e/`) provides high-level WCAG compliance checks.
|
||||
|
||||
### E2E Testing Philosophy
|
||||
|
||||
E2E tests follow a **sparse, critical-path approach** optimized for open source projects:
|
||||
|
||||
- **Focus on user value**: Test critical user journeys that span multiple pages or systems, not individual component interactions
|
||||
- **Maintainability over coverage**: Keep tests maintainable and contributor-friendly rather than comprehensive
|
||||
- **Visual regression is minimal**: Only capture screenshots of major pages (homepage, blog listing/post, 404), not every component or viewport
|
||||
- **Performance monitoring is essential**: Track homepage load and Core Web Vitals, but detailed performance analysis is handled by Lighthouse CI
|
||||
- **Edge cases are critical only**: Test scenarios that would break user experience (slow network, offline mode, JS errors, missing images)
|
||||
|
||||
This approach reduces test maintenance burden while ensuring critical functionality remains stable.
|
||||
|
||||
### Standard Component Test Suite
|
||||
|
||||
Use the shared suite in `tests/utils/componentTestSuite.tsx` to get a consistent baseline:
|
||||
|
||||
```ts
|
||||
import Component from "../../app/components/Component";
|
||||
import {
|
||||
componentTestSuite,
|
||||
type ComponentTestSuiteConfig,
|
||||
} from "../utils/componentTestSuite";
|
||||
|
||||
type Props = React.ComponentProps<typeof Component>;
|
||||
|
||||
const config: ComponentTestSuiteConfig<Props> = {
|
||||
component: Component,
|
||||
name: "Component",
|
||||
props: {
|
||||
/* default props */
|
||||
} as Props,
|
||||
requiredProps: ["label"],
|
||||
optionalProps: {
|
||||
disabled: true,
|
||||
},
|
||||
queries: {
|
||||
getPrimaryElement: (s) => s.getByRole("button"),
|
||||
},
|
||||
variants: {
|
||||
disabled: {
|
||||
props: { disabled: true },
|
||||
assert: (el) => {
|
||||
expect(el).toBeDisabled();
|
||||
},
|
||||
},
|
||||
error: {
|
||||
props: { error: true } as Partial<Props>,
|
||||
assert: (el) => {
|
||||
expect(el).toHaveClass(
|
||||
"border-[var(--color-border-default-utility-negative)]",
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
testCases: {
|
||||
renders: true,
|
||||
accessibility: true,
|
||||
keyboardNavigation: true,
|
||||
disabledState: true,
|
||||
errorState: true,
|
||||
},
|
||||
};
|
||||
|
||||
componentTestSuite<Props>(config);
|
||||
```
|
||||
|
||||
#### What the Standard Suite Covers
|
||||
|
||||
- **Rendering**
|
||||
- Component renders without throwing using the provided `props`.
|
||||
- Required props are present and do not break rendering.
|
||||
- Optional props can be applied without breaking.
|
||||
|
||||
- **Accessibility**
|
||||
- Runs `axe` against the rendered output.
|
||||
- Fails on common WCAG 2.1 issues (roles, labels, contrast, etc.).
|
||||
|
||||
- **Keyboard Navigation**
|
||||
- Ensures the primary element can receive focus.
|
||||
- Smoke‑tests basic keyboard activation (`Enter`, `Space`) without runtime errors.
|
||||
|
||||
- **Disabled State**
|
||||
- Uses `variants.disabled` to verify disabled behaviour (e.g., `aria-disabled`, `disabled` attribute, tab index).
|
||||
|
||||
- **Error State**
|
||||
- Uses `variants.error` to verify error styling/attributes when applicable.
|
||||
|
||||
### When to Add Custom Tests
|
||||
|
||||
Use the standard suite for **baseline coverage**, then add custom `describe` blocks in the same file when:
|
||||
|
||||
- The component has **important variants** (different sizes, modes, label variants).
|
||||
- There is **non‑trivial interaction** (menus, dropdowns, complex keyboard behaviour).
|
||||
- You need to exercise **stateful flows** (forms, validation, error messages).
|
||||
|
||||
Example (inside the same `*.test.tsx` file):
|
||||
|
||||
```ts
|
||||
describe("Input – behaviour specifics", () => {
|
||||
it("calls onChange when user types", async () => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Test Commands
|
||||
|
||||
- **All component tests** (Vitest + RTL):
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
- **Component-only tests** (faster inner loop, focused on `tests/components/`):
|
||||
|
||||
```bash
|
||||
npm run test:component
|
||||
# filter by name:
|
||||
npm run test:component -- --run tests/components/Button.test.tsx
|
||||
```
|
||||
|
||||
- **E2E tests only** (Playwright):
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
# or, equivalently:
|
||||
npm run e2e
|
||||
```
|
||||
|
||||
### What to Test vs. What Not to Test
|
||||
|
||||
- **Do test**
|
||||
- Public behaviour: visible text, roles, labels, ARIA, keyboard paths.
|
||||
- State transitions that users rely on (error -> success, disabled -> enabled).
|
||||
- Critical component interactions (clicks, form submissions, dropdown selection).
|
||||
- Accessibility invariants (no axe violations, basic keyboard support).
|
||||
|
||||
- **Avoid testing**
|
||||
- Pure styling details that are likely to change frequently (exact shadow radius, minor spacing).
|
||||
- Internal implementation details (private helpers, hook internals, memoisation specifics).
|
||||
- Responsive visibility in JSDOM (use Playwright visual / responsive tests instead).
|
||||
|
||||
### Adding Tests for a New Component (≈5 minutes)
|
||||
|
||||
1. **Create the component file** in `app/components/`.
|
||||
2. **Create a test file** in `tests/components/ComponentName.test.tsx`.
|
||||
3. **Wire the standard suite** using `componentTestSuite`.
|
||||
4. **Add 1–3 custom tests** for any unique behaviours.
|
||||
5. Run:
|
||||
|
||||
```bash
|
||||
npm run test:component -- --run tests/components/ComponentName.test.tsx
|
||||
```
|
||||
|
||||
### E2E and Visual Regression
|
||||
|
||||
E2E tests are organized into focused files:
|
||||
|
||||
- **`critical-journeys.spec.ts`**: Main user journeys (homepage loads, navigation, key interactions)
|
||||
- **`visual-regression.spec.ts`**: Critical page screenshots only (homepage full/viewport, blog listing/post, 404)
|
||||
- **`edge-cases.spec.ts`**: Critical error scenarios (slow network, offline mode, JS errors, missing images)
|
||||
- **`performance.spec.ts`**: Essential performance checks (homepage load, Core Web Vitals)
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
# Run all E2E tests
|
||||
npm run test:e2e
|
||||
|
||||
# Run visual regression tests only
|
||||
npm run visual:test
|
||||
|
||||
# Update visual regression snapshots (after UI changes)
|
||||
npm run visual:update
|
||||
|
||||
# Run specific test file
|
||||
npx playwright test tests/e2e/critical-journeys.spec.ts
|
||||
```
|
||||
|
||||
**When to add E2E tests:**
|
||||
|
||||
- **Add E2E tests** when:
|
||||
- A new critical user journey is introduced (e.g., new multi-step flow)
|
||||
- A major page is added that needs visual regression coverage
|
||||
- A critical error scenario needs to be tested (e.g., payment failure, form submission errors)
|
||||
|
||||
- **Don't add E2E tests** for:
|
||||
- Component-level interactions (use component tests instead)
|
||||
- Single-page functionality (use page tests instead)
|
||||
- Minor UI changes (visual regression will catch major regressions)
|
||||
- Edge cases that don't impact core user experience
|
||||
|
||||
**Visual regression snapshots:**
|
||||
|
||||
Visual regression tests capture screenshots of critical pages. When UI changes are intentional, update snapshots:
|
||||
|
||||
```bash
|
||||
npm run visual:update
|
||||
```
|
||||
|
||||
This updates snapshots for all 5 critical page tests. Review the changes carefully before committing.
|
||||
|
||||
### Storybook
|
||||
|
||||
**Storybook is used for component documentation and visual review only.** It is not used for automated testing.
|
||||
|
||||
- Component tests (`tests/components/*.test.tsx`) provide all test coverage previously handled by Storybook test-runner
|
||||
- Storybook stories (`stories/*.stories.js`) serve as living documentation and visual examples
|
||||
- Interaction functions are inlined in story files for demonstration purposes
|
||||
- Run Storybook locally with `npm run storybook` for component development and review
|
||||
|
||||
### Accessibility Testing
|
||||
|
||||
Accessibility is tested at two levels:
|
||||
|
||||
1. **Component-level accessibility** (`tests/components/*.test.tsx`):
|
||||
- Automatically covered by `componentTestSuite` using `jest-axe`
|
||||
- Tests roles, labels, ARIA attributes, keyboard navigation
|
||||
- Runs as part of every component test suite
|
||||
|
||||
2. **Full-page accessibility** (`tests/accessibility/e2e/wcag-compliance.spec.ts`):
|
||||
- E2E tests using Playwright and `@axe-core/playwright`
|
||||
- Validates WCAG 2.1 AA compliance across entire pages
|
||||
- Tests complete user journeys for accessibility barriers
|
||||
|
||||
This two-tier approach ensures both individual components and full page experiences meet accessibility standards.
|
||||
+16
-14
@@ -1,20 +1,20 @@
|
||||
# Create rule flow (custom wizard) — canonical reference
|
||||
|
||||
Product/engineering reference for the **custom** “Create rule” experience: URL order, persistence, and entry points. **Implementation work** to align code with this doc (progress bar, resume redirects, etc.) is tracked in Linear **[CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)** and [docs/backend-linear-tickets.md](backend-linear-tickets.md) **Ticket 17**.
|
||||
Product/engineering reference for the **custom** “Create rule” experience: URL order, persistence, and entry points. **Implementation work** to align code with this doc (progress bar, resume redirects, etc.) is tracked in Linear **[CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)** and [docs/guides/backend-linear-tickets.md](guides/backend-linear-tickets.md) **Ticket 17**.
|
||||
|
||||
---
|
||||
|
||||
## Product stages (Figma)
|
||||
|
||||
The Figma **Create Community** sequence is the **source of truth** for the first segment of the wizard (eight frames). After **`review`**, the flow continues with **Create Custom CommunityRule** and **Review and complete** stages. The shipped URL sequence in [`FLOW_STEP_ORDER`](../app/create/utils/flowSteps.ts) **follows that trajectory**; stages are a **product** slice of that linear order, not separate routers today.
|
||||
The Figma **Create Community** sequence is the **source of truth** for the first segment of the wizard (eight frames). After **`review`**, the flow continues with **Create Custom CommunityRule** and **Review and complete** stages. The shipped URL sequence in [`FLOW_STEP_ORDER`](../app/(app)/create/utils/flowSteps.ts) **follows that trajectory**; stages are a **product** slice of that linear order, not separate routers today.
|
||||
|
||||
| Stage (Figma) | Purpose (summary) | `CreateFlowStep` values (in order) |
|
||||
| --- | --- | --- |
|
||||
| **Create Community** | Intro, naming, structure, context, size, upload, save progress (email), then community review. | `informational` → `community-name` → `community-structure` → `community-context` → `community-size` → `community-upload` → `community-save` → `review` |
|
||||
| **Create Custom CommunityRule** | Author the CommunityRule content and structure. | `core-values` → `communication-methods` → `right-rail` (further card-stack steps get their own `screenId` and `screens/card/*Screen.tsx`; `right-rail` uses `layoutKind: "right-rail"`) |
|
||||
| **Create Custom CommunityRule** | Author the CommunityRule content and structure (core values + four card-stack categories). | `core-values` → `communication-methods` → `membership-methods` → `decision-approaches` → `conflict-management` |
|
||||
| **Review and complete** | Stakeholders, final card, publish, success. | `confirm-stakeholders` → `final-review` → `completed` |
|
||||
|
||||
Treat these stages as the **canonical product sections** when adding chrome (e.g. stage headers, progress copy), breaking work across teams, or reusing flows in other surfaces. **Layout kind** is **not** encoded in the URL; it lives in [`CREATE_FLOW_SCREEN_REGISTRY`](../app/create/utils/createFlowScreenRegistry.ts) (Figma node id + `layoutKind` per step). Figma defines eight layout kinds: **informational**, **text**, **select**, **upload**, **review**, **card**, **right-rail**, **completed** — `CreateFlowLayoutKind` and [`app/create/screens/`](../app/create/screens/) mirror that list (one folder per kind; multiple steps may share a kind, e.g. several **select** screens).
|
||||
Treat these stages as the **canonical product sections** when adding chrome (e.g. stage headers, progress copy), breaking work across teams, or reusing flows in other surfaces. **Layout kind** is **not** encoded in the URL; it lives in [`CREATE_FLOW_SCREEN_REGISTRY`](../app/(app)/create/utils/createFlowScreenRegistry.ts) (Figma node id + `layoutKind` per step). Figma defines eight layout kinds: **informational**, **text**, **select**, **upload**, **review**, **card**, **right-rail**, **completed** — `CreateFlowLayoutKind` and [`app/(app)/create/screens/`](../app/(app)/create/screens/) mirror that list (one folder per kind; multiple steps may share a kind, e.g. several **select** screens).
|
||||
|
||||
**Create from template (future):** A full **template-driven** create path is **not** finalized; it will likely live on **additional route(s)** (and may reuse these stages where it overlaps the custom trajectory). Today, **`/create/review-template/[slug]`** is only an auxiliary **preview** in the create shell; it is **not** a Figma stage and not the final template-create entry. See **Out of scope** in [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo).
|
||||
|
||||
@@ -22,7 +22,7 @@ Treat these stages as the **canonical product sections** when adding chrome (e.g
|
||||
|
||||
## Step order and URLs
|
||||
|
||||
Order is defined in code by [`FLOW_STEP_ORDER`](../app/create/utils/flowSteps.ts) and the [`CreateFlowStep`](../app/create/types.ts) type. Wizard steps use a **single dynamic route**: [`app/create/[screenId]/page.tsx`](../app/create/[screenId]/page.tsx), which validates `screenId` and renders [`CreateFlowScreenView`](../app/create/screens/CreateFlowScreenView.tsx). Implementation files are grouped under [`app/create/screens/`](../app/create/screens/) by Figma **layout kind** (subfolders: informational, text, select, upload, review, card, right-rail, completed). **`/create`** redirects to the first step.
|
||||
Order is defined in code by [`FLOW_STEP_ORDER`](../app/(app)/create/utils/flowSteps.ts) and the [`CreateFlowStep`](../app/(app)/create/types.ts) type. Wizard steps use a **single dynamic route**: [`app/(app)/create/[screenId]/page.tsx`](../app/(app)/create/[screenId]/page.tsx), which validates `screenId` and renders [`CreateFlowScreenView`](../app/(app)/create/screens/CreateFlowScreenView.tsx). Implementation files are grouped under [`app/(app)/create/screens/`](../app/(app)/create/screens/) by Figma **layout kind** (subfolders: informational, text, select, upload, review, card, right-rail, completed). **`/create`** redirects to the first step.
|
||||
|
||||
| Order | Figma stage | Step ID (`screenId`) | Path |
|
||||
| ----: | ----------- | -------------------- | ---- |
|
||||
@@ -36,14 +36,16 @@ Order is defined in code by [`FLOW_STEP_ORDER`](../app/create/utils/flowSteps.ts
|
||||
| 8 | Create Community (review frame) | `review` | `/create/review` |
|
||||
| 9 | Create Custom CommunityRule | `core-values` | `/create/core-values` |
|
||||
| 10 | Create Custom CommunityRule | `communication-methods` | `/create/communication-methods` |
|
||||
| 11 | Create Custom CommunityRule | `right-rail` | `/create/right-rail` |
|
||||
| 12 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
|
||||
| 13 | Review and complete | `final-review` | `/create/final-review` |
|
||||
| 14 | Review and complete | `completed` | `/create/completed` |
|
||||
| 11 | Create Custom CommunityRule | `membership-methods` | `/create/membership-methods` |
|
||||
| 12 | Create Custom CommunityRule | `decision-approaches` | `/create/decision-approaches` |
|
||||
| 13 | Create Custom CommunityRule | `conflict-management` | `/create/conflict-management` |
|
||||
| 14 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
|
||||
| 15 | Review and complete | `final-review` | `/create/final-review` |
|
||||
| 16 | Review and complete | `completed` | `/create/completed` |
|
||||
|
||||
**Primary entry:** marketing header “Create rule” navigates to **`/create`**, which redirects to **`/create/informational`** (see [`TopNav.container.tsx`](../app/components/navigation/TopNav/TopNav.container.tsx)).
|
||||
|
||||
Active step for chrome and navigation is resolved from the pathname via [`parseCreateFlowScreenFromPathname`](../app/create/utils/flowSteps.ts) inside [`useCreateFlowNavigation`](../app/create/hooks/useCreateFlowNavigation.ts).
|
||||
Active step for chrome and navigation is resolved from the pathname via [`parseCreateFlowScreenFromPathname`](../app/(app)/create/utils/flowSteps.ts) inside [`useCreateFlowNavigation`](../app/(app)/create/hooks/useCreateFlowNavigation.ts).
|
||||
|
||||
---
|
||||
|
||||
@@ -61,10 +63,10 @@ From that page, **Customize** currently navigates to `/create/informational?temp
|
||||
|
||||
| Mode | Where progress lives | Save & Exit / server draft |
|
||||
| --- | --- | --- |
|
||||
| **Anonymous** | `localStorage` key **`create-flow-anonymous`** | **Exit** opens save-progress magic link; after verify, optional **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (see Tickets 4–5 in [backend-linear-tickets.md](backend-linear-tickets.md)). |
|
||||
| **Anonymous** | `localStorage` key **`create-flow-anonymous`** | **Exit** opens save-progress magic link; after verify, optional **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (see Tickets 4–5 in [guides/backend-linear-tickets.md](guides/backend-linear-tickets.md)). |
|
||||
| **Signed-in** | In-memory React state in **`CreateFlowContext`** | **Save & Exit** from the **`community-structure`** step onward (step index ≥ `community-structure`) may **PUT** `/api/drafts/me` when sync is on. **Sign out** is on profile, not in the create top nav. |
|
||||
|
||||
Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticket 4**, **Ticket 5**, and [`docs/backend-roadmap.md`](backend-roadmap.md) §12.
|
||||
Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticket 4**, **Ticket 5**, and [`docs/guides/backend-roadmap.md`](guides/backend-roadmap.md) §12.
|
||||
|
||||
---
|
||||
|
||||
@@ -77,5 +79,5 @@ Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticke
|
||||
|
||||
## Related docs
|
||||
|
||||
- [docs/backend-roadmap.md](backend-roadmap.md) §12 — Frontend hook-up
|
||||
- [docs/backend-linear-tickets.md](backend-linear-tickets.md) — Tickets 4, 5, 6, 17
|
||||
- [docs/guides/backend-roadmap.md](guides/backend-roadmap.md) §12 — Frontend hook-up
|
||||
- [docs/guides/backend-linear-tickets.md](guides/backend-linear-tickets.md) — Tickets 4, 5, 6, 17
|
||||
|
||||
@@ -86,7 +86,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
**Depends on:** Ticket 1 (optional but keeps docs honest).
|
||||
|
||||
**Goal:** Replace the open `[key: string]: unknown` shape in [app/create/types.ts](app/create/types.ts) with real fields (or nested objects) agreed with design/product, and validate JSON on the server for drafts and publish.
|
||||
**Goal:** Replace the open `[key: string]: unknown` shape in [app/(app)/create/types.ts](app/(app)/create/types.ts) with real fields (or nested objects) agreed with design/product, and validate JSON on the server for drafts and publish.
|
||||
|
||||
**Context:** `PUT /api/drafts/me` and `POST /api/rules` accept loose objects today; oversized or malformed payloads are a stability and security concern.
|
||||
|
||||
@@ -107,7 +107,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
**Status:** [CR-73](https://linear.app/community-rule/issue/CR-73/backend-formalize-createflowstate-validate-draftpublish-api-payloads) **Done**.
|
||||
|
||||
**Files:** [app/create/types.ts](app/create/types.ts), [app/api/drafts/me/route.ts](app/api/drafts/me/route.ts), [app/api/rules/route.ts](app/api/rules/route.ts), [lib/server/validation/](lib/server/validation/) (Zod + plain-JSON checks), [package.json](package.json) (`zod`).
|
||||
**Files:** [app/(app)/create/types.ts](app/(app)/create/types.ts), [app/api/drafts/me/route.ts](app/api/drafts/me/route.ts), [app/api/rules/route.ts](app/api/rules/route.ts), [lib/server/validation/](lib/server/validation/) (Zod + plain-JSON checks), [package.json](package.json) (`zod`).
|
||||
|
||||
**Note:** Repo-wide **API error JSON shape** and **request-id logging** are **Ticket 13 / CR-84**—coordinate 400 response bodies with that issue so validation errors match the agreed `{ error: { code, message } }` pattern.
|
||||
|
||||
@@ -125,12 +125,12 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
**Implementation (shipped):**
|
||||
|
||||
1. **`/login`** route **and** **header modal** — primary **Log in** entry is [`AuthModalProvider`](app/contexts/AuthModalContext.tsx) + [app/components/modals/Login/](app/components/modals/Login/); [app/login/page.tsx](app/login/page.tsx) (solid shell, `usePortal={false}`) remains for verify **error** redirects and bookmarks.
|
||||
1. **`/login`** route **and** **header modal** — primary **Log in** entry is [`AuthModalProvider`](app/contexts/AuthModalContext.tsx) + [app/components/modals/Login/](app/components/modals/Login/); [app/(app)/login/page.tsx](app/(app)/login/page.tsx) (solid shell, `usePortal={false}`) remains for verify **error** redirects and bookmarks.
|
||||
2. Flow: email → “Send link” → user opens link (email, Mailhog, or dev log) → `GET /api/auth/magic-link/verify?token=...` sets session and redirects; optional `next` for post-login path.
|
||||
3. Surface API errors: invalid email, 429 `retryAfterMs`, expired/invalid token, network failure (accessible copy).
|
||||
4. Ensure `fetch` calls use `credentials: "include"` where needed (see [lib/create/api.ts](lib/create/api.ts)).
|
||||
5. **Dev:** without `SMTP_URL`, verify URL is logged; with Mailhog, use [docker-compose.yml](docker-compose.yml) and `SMTP_URL=smtp://localhost:1025`.
|
||||
6. **Marketing header:** When signed in (`fetchAuthSession`), **Log in** becomes **Profile** linking to [`/profile`](app/profile/page.tsx) (placeholder until Ticket 15 / CR-86). Implemented in [TopNavWithPathname.tsx](app/components/navigation/TopNav/TopNavWithPathname.tsx) + [TopNav.container.tsx](app/components/navigation/TopNav/TopNav.container.tsx).
|
||||
6. **Marketing header:** When signed in (`fetchAuthSession`), **Log in** becomes **Profile** linking to [`/profile`](app/(app)/profile/page.tsx) (placeholder until Ticket 15 / CR-86). Implemented in [TopNavWithPathname.tsx](app/components/navigation/TopNav/TopNavWithPathname.tsx) + [TopNav.container.tsx](app/components/navigation/TopNav/TopNav.container.tsx).
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
@@ -141,7 +141,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
**Status:** [CR-74](https://linear.app/community-rule/issue/CR-74/backend-magic-link-sign-in-ui-apis-ticket-3-cr-75-done) **Done** for shipped UI/APIs. **Residual checklist** below: repo doc items are **done**; use Linear (CR-74 or child issue) to track **per-environment** staging URL checks.
|
||||
|
||||
**Files:** [app/login/](app/login/), [app/profile/](app/profile/) (placeholder), [app/components/modals/Login/](app/components/modals/Login/), [messages/en/pages/login.json](messages/en/pages/login.json), [messages/en/pages/profile.json](messages/en/pages/profile.json), [messages/en/components/header.json](messages/en/components/header.json), [app/components/navigation/TopNav/TopNav.container.tsx](app/components/navigation/TopNav/TopNav.container.tsx), [app/components/navigation/TopNav/TopNavWithPathname.tsx](app/components/navigation/TopNav/TopNavWithPathname.tsx), [lib/create/api.ts](lib/create/api.ts), [app/api/auth/magic-link/request/route.ts](app/api/auth/magic-link/request/route.ts), [app/api/auth/magic-link/verify/route.ts](app/api/auth/magic-link/verify/route.ts), [prisma/schema.prisma](prisma/schema.prisma) (`MagicLinkToken`), [lib/server/mail.ts](lib/server/mail.ts). Onboarding: [CONTRIBUTING.md](CONTRIBUTING.md), [`.env.example`](.env.example).
|
||||
**Files:** [app/(app)/login/](app/(app)/login/), [app/(app)/profile/](app/(app)/profile/) (placeholder), [app/components/modals/Login/](app/components/modals/Login/), [messages/en/pages/login.json](messages/en/pages/login.json), [messages/en/pages/profile.json](messages/en/pages/profile.json), [messages/en/components/header.json](messages/en/components/header.json), [app/components/navigation/TopNav/TopNav.container.tsx](app/components/navigation/TopNav/TopNav.container.tsx), [app/components/navigation/TopNav/TopNavWithPathname.tsx](app/components/navigation/TopNav/TopNavWithPathname.tsx), [lib/create/api.ts](lib/create/api.ts), [app/api/auth/magic-link/request/route.ts](app/api/auth/magic-link/request/route.ts), [app/api/auth/magic-link/verify/route.ts](app/api/auth/magic-link/verify/route.ts), [prisma/schema.prisma](prisma/schema.prisma) (`MagicLinkToken`), [lib/server/mail.ts](lib/server/mail.ts). Onboarding: [CONTRIBUTING.md](CONTRIBUTING.md), [`.env.example`](.env.example).
|
||||
|
||||
### Residual / before CR-75 (create-flow session UI)
|
||||
|
||||
@@ -158,24 +158,24 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
**Depends on:** Ticket 3.
|
||||
|
||||
**Goal:** In `/create/*`, **Exit** / **Save & Exit** (from `select` onward for signed-in users) is the only top-nav chrome—no email or Sign out in the create shell. **Anonymous:** progress in **`create-flow-anonymous`** localStorage; **Exit** opens the global **Save your progress?** auth modal (magic link + `?syncDraft=1` return); after verify, [`PostLoginDraftTransfer`](app/create/PostLoginDraftTransfer.tsx) **PUT**s to `/api/drafts/me` when sync is on. **Signed-in:** **Save & Exit** **PUT**s via [`useCreateFlowExit`](app/create/hooks/useCreateFlowExit.ts) when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC`**. **Sign out** for QA lives on **[ProfilePageClient](app/profile/ProfilePageClient.tsx)**. Site **Log in** opens the same modal overlay ([`AuthModalProvider`](app/contexts/AuthModalContext.tsx)), not only `/login`.
|
||||
**Goal:** In `/create/*`, **Exit** / **Save & Exit** (from `select` onward for signed-in users) is the only top-nav chrome—no email or Sign out in the create shell. **Anonymous:** progress in **`create-flow-anonymous`** localStorage; **Exit** opens the global **Save your progress?** auth modal (magic link + `?syncDraft=1` return); after verify, [`PostLoginDraftTransfer`](app/(app)/create/PostLoginDraftTransfer.tsx) **PUT**s to `/api/drafts/me` when sync is on. **Signed-in:** **Save & Exit** **PUT**s via [`useCreateFlowExit`](app/(app)/create/hooks/useCreateFlowExit.ts) when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC`**. **Sign out** for QA lives on **[ProfilePageClient](app/(app)/profile/ProfilePageClient.tsx)**. Site **Log in** opens the same modal overlay ([`AuthModalProvider`](app/contexts/AuthModalContext.tsx)), not only `/login`.
|
||||
|
||||
**Context:** **`saveDraftOnExit`** is gated on **session + step ≥ select**. Layout **`fetchAuthSession`** drives anonymous vs authenticated persistence and exit behavior. **Save & Exit** styling: Figma [20907:212637](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20907-212637). Save-progress exit modal: Figma `22398:23743`.
|
||||
|
||||
**Implementation (repo):**
|
||||
|
||||
1. [app/create/layout.tsx](app/create/layout.tsx): session + `enableAnonymousPersistence`; anonymous exit → `openLogin({ variant: 'saveProgress', nextPath })`; signed-in exit → `useCreateFlowExit`.
|
||||
1. [app/(app)/create/layout.tsx](app/(app)/create/layout.tsx): session + `enableAnonymousPersistence`; anonymous exit → `openLogin({ variant: 'saveProgress', nextPath })`; signed-in exit → `useCreateFlowExit`.
|
||||
2. [CreateFlowTopNav](app/components/utility/CreateFlowTopNav/): i18n [`messages/en/create/topNav.json`](messages/en/create/topNav.json); logo + Share/Export/Edit (completed) + Exit/Save & Exit only.
|
||||
3. [useCreateFlowExit](app/create/hooks/useCreateFlowExit.ts): `saveDraftToServer` when sync + signed in; `clearState` + home.
|
||||
4. [CreateFlowContext](app/create/context/CreateFlowContext.tsx): optional anonymous localStorage mirror via `enableAnonymousPersistence`.
|
||||
5. **QA:** [ProfilePageClient](app/profile/ProfilePageClient.tsx) Sign out when session present.
|
||||
3. [useCreateFlowExit](app/(app)/create/hooks/useCreateFlowExit.ts): `saveDraftToServer` when sync + signed in; `clearState` + home.
|
||||
4. [CreateFlowContext](app/(app)/create/context/CreateFlowContext.tsx): optional anonymous localStorage mirror via `enableAnonymousPersistence`.
|
||||
5. **QA:** [ProfilePageClient](app/(app)/profile/ProfilePageClient.tsx) Sign out when session present.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [x] Completed step still works; **Save & Exit** gating uses session + step (not conflated with `completed` only).
|
||||
- [x] Signed in + sync: Save & Exit persists server-side; anonymous: localStorage + exit modal + transfer after magic link. Sign out on profile clears session. _(Re-verify on staging/prod as needed.)_
|
||||
|
||||
**Files:** [app/create/layout.tsx](app/create/layout.tsx), [app/create/hooks/useCreateFlowExit.ts](app/create/hooks/useCreateFlowExit.ts), [app/components/utility/CreateFlowTopNav/](app/components/utility/CreateFlowTopNav/), [app/create/context/CreateFlowContext.tsx](app/create/context/CreateFlowContext.tsx), [messages/en/create/topNav.json](messages/en/create/topNav.json), [app/profile/ProfilePageClient.tsx](app/profile/ProfilePageClient.tsx).
|
||||
**Files:** [app/(app)/create/layout.tsx](app/(app)/create/layout.tsx), [app/(app)/create/hooks/useCreateFlowExit.ts](app/(app)/create/hooks/useCreateFlowExit.ts), [app/components/utility/CreateFlowTopNav/](app/components/utility/CreateFlowTopNav/), [app/(app)/create/context/CreateFlowContext.tsx](app/(app)/create/context/CreateFlowContext.tsx), [messages/en/create/topNav.json](messages/en/create/topNav.json), [app/(app)/profile/ProfilePageClient.tsx](app/(app)/profile/ProfilePageClient.tsx).
|
||||
|
||||
---
|
||||
|
||||
@@ -189,9 +189,9 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. **Hydration:** **Done:** [SignedInDraftHydration](app/create/SignedInDraftHydration.tsx) + [messages/en/create/draftHydration.json](messages/en/create/draftHydration.json); skips `?syncDraft=1` / transfer-pending (PostLogin owns that). Wired in [layout](app/create/layout.tsx).
|
||||
2. **Conflict:** **Done:** If `create-flow-anonymous` and server draft are both non-empty, `window.confirm` (OK = account draft, Cancel = browser copy); documented on [anonymousDraftStorage](app/create/utils/anonymousDraftStorage.ts). Newer-`updatedAt` client compare remains optional.
|
||||
3. **Save failures (API surface):** **Done (CR-76):** [saveDraftToServer](lib/create/api.ts) returns `SaveDraftResult` with parsed API `message`; wired in [useCreateFlowExit](app/create/hooks/useCreateFlowExit.ts) and [PostLoginDraftTransfer](app/create/PostLoginDraftTransfer.tsx).
|
||||
1. **Hydration:** **Done:** [SignedInDraftHydration](app/(app)/create/SignedInDraftHydration.tsx) + [messages/en/create/draftHydration.json](messages/en/create/draftHydration.json); skips `?syncDraft=1` / transfer-pending (PostLogin owns that). Wired in [layout](app/(app)/create/layout.tsx).
|
||||
2. **Conflict:** **Done:** If `create-flow-anonymous` and server draft are both non-empty, `window.confirm` (OK = account draft, Cancel = browser copy); documented on [anonymousDraftStorage](app/(app)/create/utils/anonymousDraftStorage.ts). Newer-`updatedAt` client compare remains optional.
|
||||
3. **Save failures (API surface):** **Done (CR-76):** [saveDraftToServer](lib/create/api.ts) returns `SaveDraftResult` with parsed API `message`; wired in [useCreateFlowExit](app/(app)/create/hooks/useCreateFlowExit.ts) and [PostLoginDraftTransfer](app/(app)/create/PostLoginDraftTransfer.tsx).
|
||||
4. **Save failures (UX):** **Done (CR-76):** Dismissible banner with server `message` (no second confirm to leave); post-login transfer shows reason; unit tests in `tests/unit/saveDraftToServer.test.ts`. Retry/backoff remains optional.
|
||||
5. **Tests:** `saveDraftToServer` unit tests; [draftHydrationUtils](lib/create/draftHydrationUtils.ts) unit tests. Playwright against Next standalone + route mocks for `/api/auth/session` was flaky here; cover hydration with **manual QA** (signed in + sync on + server draft) or add a future E2E with a dedicated auth fixture.
|
||||
|
||||
@@ -200,7 +200,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
- [x] No silent data loss when server save fails (user sees reason in banner; stays in flow to retry Save & Exit or leave via e.g. logo).
|
||||
- [x] User understands when server draft replaced local state (if applicable) — conflict `window.confirm` when both browser anonymous draft and account draft exist; otherwise silent apply of single source.
|
||||
|
||||
**Files:** [lib/create/api.ts](lib/create/api.ts), [app/create/hooks/useCreateFlowExit.ts](app/create/hooks/useCreateFlowExit.ts), [app/create/PostLoginDraftTransfer.tsx](app/create/PostLoginDraftTransfer.tsx), [app/create/SignedInDraftHydration.tsx](app/create/SignedInDraftHydration.tsx), [app/create/layout.tsx](app/create/layout.tsx), [CreateFlowContext](app/create/context/CreateFlowContext.tsx), tests under `tests/`.
|
||||
**Files:** [lib/create/api.ts](lib/create/api.ts), [app/(app)/create/hooks/useCreateFlowExit.ts](app/(app)/create/hooks/useCreateFlowExit.ts), [app/(app)/create/PostLoginDraftTransfer.tsx](app/(app)/create/PostLoginDraftTransfer.tsx), [app/(app)/create/SignedInDraftHydration.tsx](app/(app)/create/SignedInDraftHydration.tsx), [app/(app)/create/layout.tsx](app/(app)/create/layout.tsx), [CreateFlowContext](app/(app)/create/context/CreateFlowContext.tsx), tests under `tests/`.
|
||||
|
||||
---
|
||||
|
||||
@@ -210,7 +210,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
**Goal:** Completing the flow persists a **PublishedRule** via existing [publishRule](lib/create/api.ts).
|
||||
|
||||
**Context:** [lib/create/api.ts](lib/create/api.ts) already wraps `POST /api/rules`. UI on the `final-review` / `completed` steps (see [app/create/screens/CreateFlowScreenView.tsx](app/create/screens/CreateFlowScreenView.tsx) and `app/create/screens/`) must call it with `{ title, summary?, document }` derived from `CreateFlowState`.
|
||||
**Context:** [lib/create/api.ts](lib/create/api.ts) already wraps `POST /api/rules`. UI on the `final-review` / `completed` steps (see [app/(app)/create/screens/CreateFlowScreenView.tsx](app/(app)/create/screens/CreateFlowScreenView.tsx) and `app/(app)/create/screens/`) must call it with `{ title, summary?, document }` derived from `CreateFlowState`.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
@@ -224,7 +224,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
- [ ] Published row appears in Postgres (`PublishedRule`) and `GET /api/rules` lists it.
|
||||
- [ ] User sees clear success/failure.
|
||||
|
||||
**Files:** relevant `app/create/*/page.tsx`, [lib/create/api.ts](lib/create/api.ts) if request shape changes, types from Ticket 2.
|
||||
**Files:** relevant `app/(app)/create/*/page.tsx`, [lib/create/api.ts](lib/create/api.ts) if request shape changes, types from Ticket 2.
|
||||
|
||||
---
|
||||
|
||||
@@ -258,7 +258,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
**Goal:** Home or create entry surfaces use live template data instead of only static i18n JSON.
|
||||
|
||||
**Context:** [RuleStack.view.tsx](app/components/sections/RuleStack/RuleStack.view.tsx) and create entry surfaces reference future template work. Wizard URLs are static segments under `app/create/`; see [`docs/create-flow.md`](create-flow.md) and **Ticket 17** for the canonical custom flow.
|
||||
**Context:** [RuleStack.view.tsx](app/components/sections/RuleStack/RuleStack.view.tsx) and create entry surfaces reference future template work. Wizard URLs are static segments under `app/(app)/create/`; see [`docs/create-flow.md`](create-flow.md) and **Ticket 17** for the canonical custom flow.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
@@ -271,7 +271,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
- [ ] Changing a template row in Prisma Studio reflects after refresh (or revalidate).
|
||||
- [ ] No layout shift regression on LCP-critical pages (use skeletons).
|
||||
|
||||
**Files:** [app/components/sections/RuleStack/](app/components/sections/RuleStack/), create-flow entry routes under [app/create/](app/create/), possibly new `lib/templates/fetchTemplates.ts`.
|
||||
**Files:** [app/components/sections/RuleStack/](app/components/sections/RuleStack/), create-flow entry routes under [app/(app)/create/](app/(app)/create/), possibly new `lib/templates/fetchTemplates.ts`.
|
||||
|
||||
**Follow-up:** **Ticket 16** — dynamic recommendations from authoring spreadsheets and create-flow answers.
|
||||
|
||||
@@ -311,14 +311,14 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
**Goal:** Establish the **official custom** create-rule flow (ordered steps, URLs, persistence, entry points, **Figma three-stage framing**) in repo docs and close gaps between that spec and the implementation (routing clutter, progress UI, step source of truth, resume vs URL).
|
||||
|
||||
**Context:** Step order lives in [`app/create/utils/flowSteps.ts`](app/create/utils/flowSteps.ts). Wizard screens render from [`app/create/[screenId]/page.tsx`](app/create/[screenId]/page.tsx) plus [`CREATE_FLOW_SCREEN_REGISTRY`](app/create/utils/createFlowScreenRegistry.ts) (Figma node + layout family per slug). [`docs/create-flow.md`](create-flow.md) is the **canonical** URL/persistence summary: **Create Community** (eight semantic steps ending at `review`) → **Create Custom CommunityRule** → **Review and complete**. **Full create-from-template** will likely use **additional route(s)** when product defines it; **`/create/review-template/[slug]`** remains auxiliary preview only. **Template → `final-review` or mid-wizard prefill** is **out of scope** here (future ticket); `/create/informational?template=` is a **no-op** until then.
|
||||
**Context:** Step order lives in [`app/(app)/create/utils/flowSteps.ts`](app/(app)/create/utils/flowSteps.ts). Wizard screens render from [`app/(app)/create/[screenId]/page.tsx`](app/(app)/create/[screenId]/page.tsx) plus [`CREATE_FLOW_SCREEN_REGISTRY`](app/(app)/create/utils/createFlowScreenRegistry.ts) (Figma node + layout family per slug). [`docs/create-flow.md`](create-flow.md) is the **canonical** URL/persistence summary: **Create Community** (eight semantic steps ending at `review`) → **Create Custom CommunityRule** → **Review and complete**. **Full create-from-template** will likely use **additional route(s)** when product defines it; **`/create/review-template/[slug]`** remains auxiliary preview only. **Template → `final-review` or mid-wizard prefill** is **out of scope** here (future ticket); `/create/informational?template=` is a **no-op** until then.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. Keep [`docs/create-flow.md`](create-flow.md) in sync with product/Figma (stage ↔ step mapping, future template routes).
|
||||
2. ~~Remove legacy [`app/create/[step]/page.tsx`](app/create/[step]/page.tsx)~~ — replaced by [`app/create/[screenId]/page.tsx`](app/create/[screenId]/page.tsx) with real screens; unknown slugs `notFound()`.
|
||||
3. Unify **step source of truth**: URL via [`useCreateFlowNavigation`](app/create/hooks/useCreateFlowNavigation.ts) vs unused [`CreateFlowContext`](app/create/context/CreateFlowContext.tsx) `currentStep` — pick one model; align [`useCreateFlowExit`](app/create/hooks/useCreateFlowExit.ts) / draft payload if needed.
|
||||
4. **Resume:** After [`SignedInDraftHydration`](app/create/SignedInDraftHydration.tsx), decide redirect to `/create/${state.currentStep}` vs stay on current URL; test or document.
|
||||
2. ~~Remove legacy [`app/(app)/create/[step]/page.tsx`](app/(app)/create/[step]/page.tsx)~~ — replaced by [`app/(app)/create/[screenId]/page.tsx`](app/(app)/create/[screenId]/page.tsx) with real screens; unknown slugs `notFound()`.
|
||||
3. Unify **step source of truth**: URL via [`useCreateFlowNavigation`](app/(app)/create/hooks/useCreateFlowNavigation.ts) vs unused [`CreateFlowContext`](app/(app)/create/context/CreateFlowContext.tsx) `currentStep` — pick one model; align [`useCreateFlowExit`](app/(app)/create/hooks/useCreateFlowExit.ts) / draft payload if needed.
|
||||
4. **Resume:** After [`SignedInDraftHydration`](app/(app)/create/SignedInDraftHydration.tsx), decide redirect to `/create/${state.currentStep}` vs stay on current URL; test or document.
|
||||
5. Wire [`CreateFlowFooter`](app/components/utility/CreateFlowFooter/) `ProportionBar` to step progress from `FLOW_STEP_ORDER` (and `review-template` / `completed` exceptions per design); optional **two-level progress** (stage + step within stage) when design specifies.
|
||||
6. When Figma hands off, surface **stage labels** in create shell (top nav, footer, or step chrome) using the mapping in `create-flow.md`.
|
||||
|
||||
@@ -330,7 +330,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
- [ ] Hydration + `currentStep` behavior is verified (redirect vs stay).
|
||||
- [ ] `?template=` documented as deferred; no implied “template customize → full wizard” parity.
|
||||
|
||||
**Files:** [`docs/create-flow.md`](create-flow.md), [`app/create/`](app/create/), [`app/components/utility/CreateFlowFooter/`](app/components/utility/CreateFlowFooter/), optionally [`docs/backend-roadmap.md`](backend-roadmap.md) §12 cross-links.
|
||||
**Files:** [`docs/create-flow.md`](create-flow.md), [`app/(app)/create/`](app/(app)/create/), [`app/components/utility/CreateFlowFooter/`](app/components/utility/CreateFlowFooter/), optionally [`docs/backend-roadmap.md`](backend-roadmap.md) §12 cross-links.
|
||||
|
||||
**Linear:** [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) (**Backlog**). **Parallel** to templates (7–8) and publish (6); not part of **CR-72 → CR-83**.
|
||||
|
||||
@@ -9,7 +9,7 @@ Temporary working notes for building the backend. Safe to delete once the stack
|
||||
- **Next.js 16** single repo ([`package.json`](package.json)).
|
||||
- **PostgreSQL + Prisma**: schema and migrations under `prisma/`; product APIs under `app/api/*` (health, auth/magic-link, session, drafts, rules, templates, web-vitals).
|
||||
- **Server modules** in `lib/server/` (db, session, mail, rate limiting, etc.).
|
||||
- **Create flow:** **Anonymous** users mirror in-progress state to **`create-flow-anonymous`** in `localStorage`; **Exit** opens the save-progress magic-link modal; after verify, [`PostLoginDraftTransfer`](app/create/PostLoginDraftTransfer.tsx) can **PUT** `/api/drafts/me` when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`**. **Signed-in** users get a **fresh** in-memory session per “Create rule” entry, but with sync on the layout may **hydrate** from **`GET /api/drafts/me`** via [`SignedInDraftHydration`](app/create/SignedInDraftHydration.tsx); **Save & Exit** (from `community-structure` onward) **PUT**s when sync is on. **Log in** from the marketing header uses the global modal ([`AuthModalProvider`](app/contexts/AuthModalContext.tsx)); **`/login`** remains for verify errors and deep links. **Step order and URLs:** [`docs/create-flow.md`](docs/create-flow.md) and [`app/create/utils/flowSteps.ts`](app/create/utils/flowSteps.ts).
|
||||
- **Create flow:** **Anonymous** users mirror in-progress state to **`create-flow-anonymous`** in `localStorage`; **Exit** opens the save-progress magic-link modal; after verify, [`PostLoginDraftTransfer`](app/(app)/create/PostLoginDraftTransfer.tsx) can **PUT** `/api/drafts/me` when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`**. **Signed-in** users get a **fresh** in-memory session per “Create rule” entry, but with sync on the layout may **hydrate** from **`GET /api/drafts/me`** via [`SignedInDraftHydration`](app/(app)/create/SignedInDraftHydration.tsx); **Save & Exit** (from `community-structure` onward) **PUT**s when sync is on. **Log in** from the marketing header uses the global modal ([`AuthModalProvider`](app/contexts/AuthModalContext.tsx)); **`/login`** remains for verify errors and deep links. **Step order and URLs:** [`docs/create-flow.md`](docs/create-flow.md) and [`app/(app)/create/utils/flowSteps.ts`](app/(app)/create/utils/flowSteps.ts).
|
||||
- **Web vitals** [`app/api/web-vitals/route.ts`](app/api/web-vitals/route.ts) still use **file-based** storage under `.next` (not suitable for multi-instance production).
|
||||
- **CI:** [`.gitea/workflows/ci.yaml`](.gitea/workflows/ci.yaml) (build, test, lint, `prisma validate`); no in-repo production deploy definition.
|
||||
|
||||
@@ -88,7 +88,7 @@ Plain-English entities (names can evolve):
|
||||
|
||||
**RuleDraft future (not v1):** versioning, multiple drafts per user, easier corruption recovery—only if product needs them.
|
||||
|
||||
Align JSON shapes with `app/create/types.ts` as it matures.
|
||||
Align JSON shapes with `app/(app)/create/types.ts` as it matures.
|
||||
|
||||
---
|
||||
|
||||
@@ -218,7 +218,7 @@ npm run dev
|
||||
|
||||
## 12. Frontend hook-up
|
||||
|
||||
**Step 1.** **Anonymous** create flow: in-progress state is stored in **`create-flow-anonymous`** (`localStorage`). **Signed-in** users: when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`**, the create layout may **hydrate** in-memory flow state from **`GET /api/drafts/me`** once per session ([`SignedInDraftHydration`](../app/create/SignedInDraftHydration.tsx)), including conflict handling if anonymous storage also has data. Without sync, signed-in progress stays **in memory** until **Save & Exit** (no automatic server read on entry). **Canonical wizard step order, URLs, and Figma product stages** (**Create Community** → **Create Custom CommunityRule** → **Review and complete**) are documented in [`docs/create-flow.md`](create-flow.md). The route **`/create/review-template/[slug]`** is an **auxiliary** template preview (not a numbered wizard step); a **full create-from-template** path will likely be **separate route(s)** when defined. **Prefilling the wizard or landing on `final-review` from a template** is **not** shipped yet — see **[CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)** / Ticket 17 in [docs/backend-linear-tickets.md](backend-linear-tickets.md).
|
||||
**Step 1.** **Anonymous** create flow: in-progress state is stored in **`create-flow-anonymous`** (`localStorage`). **Signed-in** users: when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`**, the create layout may **hydrate** in-memory flow state from **`GET /api/drafts/me`** once per session ([`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx)), including conflict handling if anonymous storage also has data. Without sync, signed-in progress stays **in memory** until **Save & Exit** (no automatic server read on entry). **Canonical wizard step order, URLs, and Figma product stages** (**Create Community** → **Create Custom CommunityRule** → **Review and complete**) are documented in [`docs/create-flow.md`](create-flow.md). The route **`/create/review-template/[slug]`** is an **auxiliary** template preview (not a numbered wizard step); a **full create-from-template** path will likely be **separate route(s)** when defined. **Prefilling the wizard or landing on `final-review` from a template** is **not** shipped yet — see **[CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)** / Ticket 17 in [docs/backend-linear-tickets.md](backend-linear-tickets.md).
|
||||
|
||||
**Step 2.** Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` to enable **PUT** on **Save & Exit** and after **magic-link transfer** from the save-progress exit modal.
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
# Container/Presentation Pattern
|
||||
|
||||
## Overview
|
||||
|
||||
The Container/Presentation pattern separates component logic from presentation, improving testability, reusability, and maintainability. This pattern is now the standard for complex components in this codebase.
|
||||
|
||||
## Motivation
|
||||
|
||||
### Benefits
|
||||
|
||||
- **Testability**: Pure presentation components can be tested independently with simple prop assertions
|
||||
- **Reusability**: Presentation components can be reused with different data sources or logic
|
||||
- **Maintainability**: Clear separation makes it easier to locate and modify specific concerns
|
||||
- **Performance**: Easier to optimize rendering with React.memo on pure components
|
||||
|
||||
### When to Use
|
||||
|
||||
Use this pattern for components that have:
|
||||
|
||||
- Business logic or state management
|
||||
- Data fetching or API calls
|
||||
- Analytics tracking
|
||||
- Complex event handlers
|
||||
- Custom hooks usage
|
||||
- Dynamic imports or side effects
|
||||
|
||||
Simple presentational components (e.g., `Button`, `Avatar`) can remain as single files.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
Each component following this pattern should have this structure:
|
||||
|
||||
```
|
||||
app/components/[ComponentName]/
|
||||
├── index.tsx # Exports container as default
|
||||
├── [ComponentName].container.tsx # Logic, hooks, state management
|
||||
├── [ComponentName].view.tsx # Pure presentation component
|
||||
└── [ComponentName].types.ts # Shared TypeScript types
|
||||
```
|
||||
|
||||
### File Responsibilities
|
||||
|
||||
#### `index.tsx`
|
||||
|
||||
- Exports the container component as the default export
|
||||
- Optionally exports types for external use
|
||||
- Maintains backward compatibility with existing import paths
|
||||
|
||||
```typescript
|
||||
export { default } from "./AskOrganizer.container";
|
||||
export type { AskOrganizerProps } from "./AskOrganizer.types";
|
||||
```
|
||||
|
||||
#### `[ComponentName].container.tsx`
|
||||
|
||||
**Contains all logic:**
|
||||
|
||||
- React hooks (`useState`, `useEffect`, custom hooks)
|
||||
- Event handlers and business logic
|
||||
- Data fetching and API calls
|
||||
- Analytics tracking
|
||||
- State management
|
||||
- Computed values and derived state
|
||||
- Side effects
|
||||
|
||||
**Should NOT contain:**
|
||||
|
||||
- JSX layout details (beyond composing the view)
|
||||
- Inline styles or complex className logic (pass as props)
|
||||
- Direct DOM manipulation
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useAnalytics } from "../../hooks";
|
||||
import { AskOrganizerView } from "./AskOrganizer.view";
|
||||
import type { AskOrganizerProps } from "./AskOrganizer.types";
|
||||
|
||||
function AskOrganizerContainer(props: AskOrganizerProps) {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const handleContactClick = () => {
|
||||
trackEvent({
|
||||
event: "contact_button_click",
|
||||
category: "engagement",
|
||||
component: "AskOrganizer",
|
||||
});
|
||||
// ... additional logic
|
||||
};
|
||||
|
||||
// Compute derived props
|
||||
const variantStyles = computeVariantStyles(props.variant);
|
||||
|
||||
return (
|
||||
<AskOrganizerView
|
||||
{...props}
|
||||
onContactClick={handleContactClick}
|
||||
variantStyles={variantStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(AskOrganizerContainer);
|
||||
```
|
||||
|
||||
#### `[ComponentName].view.tsx`
|
||||
|
||||
**Pure presentation:**
|
||||
|
||||
- Receives all data via props
|
||||
- Renders JSX based on props
|
||||
- No hooks, no state, no side effects
|
||||
- Only imports other presentational components
|
||||
|
||||
**Should NOT contain:**
|
||||
|
||||
- `useState`, `useEffect`, or any hooks
|
||||
- Event handler implementations (receive as callbacks)
|
||||
- Data fetching or API calls
|
||||
- Analytics tracking
|
||||
- Business logic
|
||||
|
||||
```typescript
|
||||
import ContentLockup from "../ContentLockup";
|
||||
import Button from "../Button";
|
||||
import type { AskOrganizerViewProps } from "./AskOrganizer.types";
|
||||
|
||||
export function AskOrganizerView({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
variant,
|
||||
onContactClick,
|
||||
variantStyles,
|
||||
...props
|
||||
}: AskOrganizerViewProps) {
|
||||
return (
|
||||
<section className={variantStyles.container}>
|
||||
<ContentLockup
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
description={description}
|
||||
/>
|
||||
<Button
|
||||
href={buttonHref}
|
||||
onClick={onContactClick}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### `[ComponentName].types.ts`
|
||||
|
||||
- Shared TypeScript interfaces and types
|
||||
- Public props interface (used by consumers)
|
||||
- Internal view props (used between container and view)
|
||||
- Any utility types specific to the component
|
||||
|
||||
```typescript
|
||||
export interface AskOrganizerProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
variant?: "centered" | "left-aligned" | "compact" | "inverse";
|
||||
onContactClick?: (data: ContactClickData) => void;
|
||||
}
|
||||
|
||||
export interface AskOrganizerViewProps extends AskOrganizerProps {
|
||||
onContactClick: () => void;
|
||||
variantStyles: {
|
||||
container: string;
|
||||
buttonContainer: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Rules of Thumb
|
||||
|
||||
### Container Components
|
||||
|
||||
✅ **DO:**
|
||||
|
||||
- Use React hooks (`useState`, `useEffect`, custom hooks)
|
||||
- Handle all event handlers and business logic
|
||||
- Fetch data and manage loading states
|
||||
- Track analytics events
|
||||
- Compute derived values from props/state
|
||||
- Compose the view component with computed props
|
||||
|
||||
❌ **DON'T:**
|
||||
|
||||
- Include complex JSX layout (delegate to view)
|
||||
- Mix presentation logic with business logic
|
||||
- Access DOM directly (use refs when necessary)
|
||||
|
||||
### View Components
|
||||
|
||||
✅ **DO:**
|
||||
|
||||
- Receive all data via props
|
||||
- Render JSX based on props
|
||||
- Import only presentational components
|
||||
- Use simple conditional rendering
|
||||
- Accept callback props for user interactions
|
||||
|
||||
❌ **DON'T:**
|
||||
|
||||
- Use any React hooks
|
||||
- Manage state or side effects
|
||||
- Fetch data or make API calls
|
||||
- Track analytics directly
|
||||
- Implement business logic
|
||||
- Access browser APIs directly
|
||||
|
||||
## Example: AskOrganizer
|
||||
|
||||
### Before (Monolithic)
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useAnalytics } from "../hooks";
|
||||
import ContentLockup from "./ContentLockup";
|
||||
import Button from "./Button";
|
||||
|
||||
const AskOrganizer = memo(({ title, variant, ...props }) => {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const handleContactClick = () => {
|
||||
trackEvent({ event: "contact_click", component: "AskOrganizer" });
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<ContentLockup title={title} />
|
||||
<Button onClick={handleContactClick}>Ask an organizer</Button>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### After (Container/Presentation)
|
||||
|
||||
**AskOrganizer.container.tsx:**
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useAnalytics } from "../../hooks";
|
||||
import { AskOrganizerView } from "./AskOrganizer.view";
|
||||
import type { AskOrganizerProps } from "./AskOrganizer.types";
|
||||
|
||||
function AskOrganizerContainer(props: AskOrganizerProps) {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const handleContactClick = () => {
|
||||
trackEvent({ event: "contact_click", component: "AskOrganizer" });
|
||||
};
|
||||
|
||||
return <AskOrganizerView {...props} onContactClick={handleContactClick} />;
|
||||
}
|
||||
|
||||
export default memo(AskOrganizerContainer);
|
||||
```
|
||||
|
||||
**AskOrganizer.view.tsx:**
|
||||
|
||||
```typescript
|
||||
import ContentLockup from "../ContentLockup";
|
||||
import Button from "../Button";
|
||||
import type { AskOrganizerViewProps } from "./AskOrganizer.types";
|
||||
|
||||
export function AskOrganizerView({
|
||||
title,
|
||||
onContactClick,
|
||||
...props
|
||||
}: AskOrganizerViewProps) {
|
||||
return (
|
||||
<section>
|
||||
<ContentLockup title={title} />
|
||||
<Button onClick={onContactClick}>Ask an organizer</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
When converting an existing component to this pattern:
|
||||
|
||||
- [ ] **Identify separation points**
|
||||
- [ ] List all hooks and state management
|
||||
- [ ] List all event handlers and business logic
|
||||
- [ ] List all data fetching and side effects
|
||||
- [ ] Identify pure presentation JSX
|
||||
|
||||
- [ ] **Create folder structure**
|
||||
- [ ] Create `[ComponentName]/` folder
|
||||
- [ ] Create `[ComponentName].types.ts` with shared types
|
||||
- [ ] Create `[ComponentName].view.tsx` with pure presentation
|
||||
- [ ] Create `[ComponentName].container.tsx` with all logic
|
||||
- [ ] Create `index.tsx` exporting container
|
||||
|
||||
- [ ] **Extract types**
|
||||
- [ ] Move component props interface to `types.ts`
|
||||
- [ ] Create view props interface extending container props
|
||||
- [ ] Export types from `index.tsx` for external use
|
||||
|
||||
- [ ] **Move presentation to view**
|
||||
- [ ] Copy JSX to view component
|
||||
- [ ] Remove all hooks and state
|
||||
- [ ] Replace event handlers with callback props
|
||||
- [ ] Replace computed values with props
|
||||
- [ ] Ensure view is a pure function
|
||||
|
||||
- [ ] **Move logic to container**
|
||||
- [ ] Move all hooks to container
|
||||
- [ ] Move event handlers to container
|
||||
- [ ] Move data fetching to container
|
||||
- [ ] Compute derived props in container
|
||||
- [ ] Render view component with computed props
|
||||
|
||||
- [ ] **Update exports**
|
||||
- [ ] Export container as default from `index.tsx`
|
||||
- [ ] Export types from `index.tsx`
|
||||
- [ ] Delete original component file
|
||||
- [ ] Verify import paths still work
|
||||
|
||||
- [ ] **Update tests**
|
||||
- [ ] Verify tests still pass (imports should resolve automatically)
|
||||
- [ ] Update any tests that relied on implementation details
|
||||
- [ ] Add tests for view component with mocked props if needed
|
||||
|
||||
- [ ] **Update Storybook**
|
||||
- [ ] Verify stories still work (imports should resolve automatically)
|
||||
- [ ] Optionally add view-only stories with mocked props
|
||||
|
||||
## Refactored Components
|
||||
|
||||
The following components have been refactored to use this pattern:
|
||||
|
||||
- ✅ **AskOrganizer** - Analytics tracking and event handlers
|
||||
- ✅ **NumberedCards** - Schema generation with `useSchemaData` hook
|
||||
- ✅ **FeatureGrid** - Memoized feature data structures
|
||||
- ✅ **WebVitalsDashboard** - Dynamic imports, data fetching, complex state
|
||||
- ✅ **Select** - Complex form state management with refs and keyboard navigation
|
||||
|
||||
These components serve as reference implementations for the pattern.
|
||||
|
||||
## Remaining Components
|
||||
|
||||
The following components are candidates for future conversion:
|
||||
|
||||
### High Priority (Complex Logic)
|
||||
|
||||
- `Header` / `HomeHeader` - Navigation state, conditional rendering logic
|
||||
- `MenuBar` - Menu state management, keyboard navigation
|
||||
- `ContextMenu` - Positioning logic, click outside handling
|
||||
- `RadioGroup` - Group state management
|
||||
- `ToggleGroup` - Group state management
|
||||
|
||||
### Medium Priority (Some Logic)
|
||||
|
||||
- `ContentContainer` - Data fetching or transformation
|
||||
- `RelatedArticles` - Data fetching, filtering logic
|
||||
- `RuleStack` - Complex rendering logic
|
||||
- `LogoWall` - Animation or interaction logic
|
||||
|
||||
### Low Priority (Mostly Presentational)
|
||||
|
||||
- `Button`, `Avatar`, `Checkbox`, `Input`, `TextArea` - Simple presentational components
|
||||
- `Separator`, `SectionHeader`, `SectionNumber` - Pure presentation
|
||||
- `QuoteBlock`, `QuoteDecor`, `HeroDecor` - Decorative components
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start with complex components** - Components with the most logic benefit most from separation
|
||||
2. **Keep it simple** - Don't over-engineer simple presentational components
|
||||
3. **Maintain backward compatibility** - Import paths should remain unchanged
|
||||
4. **Test both layers** - Test container for logic, view for presentation
|
||||
5. **Document the pattern** - Add comments explaining non-obvious prop flows
|
||||
6. **Use TypeScript strictly** - Leverage types to enforce the separation
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [React Container/Presenter Pattern](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)
|
||||
- [Separation of Concerns in React](https://react.dev/learn/thinking-in-react)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: April 2025
|
||||
**Maintained by**: CommunityRule Development Team
|
||||
@@ -1,382 +1,81 @@
|
||||
# i18n Translation Workflow Guide
|
||||
# Translations & UI copy workflow
|
||||
|
||||
This guide explains how to work with translations in the CommunityRule application. The app uses a hybrid approach combining globalized, shared UI elements with context-aware, localized content pages, making it easy for content creators and contributors to update text without modifying component code.
|
||||
This guide is for **content editors** updating user-visible text. The
|
||||
implementation contract (file layout, `useMessages` access pattern, key
|
||||
casing rules) lives in `.cursor/rules/localization.mdc`.
|
||||
|
||||
## Overview
|
||||
## Where copy lives
|
||||
|
||||
All UI text is stored in JSON files under `messages/en/`. The structure follows best practices:
|
||||
All UI text is JSON under `messages/en/`:
|
||||
|
||||
- **Page-specific content** lives in `pages/` (varies by page context)
|
||||
- **Component defaults** live in `components/` (shared across pages)
|
||||
- **Common strings** live in `common.json` (shared UI elements)
|
||||
|
||||
Components reference these translations using keys, allowing content to be edited independently of the codebase.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
messages/
|
||||
en/
|
||||
pages/
|
||||
home.json # Home page specific content
|
||||
learn.json # Learn page specific content
|
||||
components/
|
||||
heroBanner.json # Component defaults (aria-labels, alt texts)
|
||||
numberedCards.json # Component defaults
|
||||
askOrganizer.json # Component defaults
|
||||
featureGrid.json # Component defaults
|
||||
footer.json # Shared across pages
|
||||
header.json # Shared across pages
|
||||
common.json # Shared UI strings (buttons, links, labels)
|
||||
navigation.json # Navigation items
|
||||
metadata.json # Page metadata (title, description)
|
||||
index.ts # Exports all messages
|
||||
```text
|
||||
messages/en/
|
||||
common.json # shared strings (buttons, links, generic labels)
|
||||
navigation.json # site nav items
|
||||
metadata.json # page <title> / description
|
||||
pages/<slug>.json # one file per page (home, learn, …)
|
||||
components/<name>.json # one file per shared component default
|
||||
create/<step>.json # one file per create-flow step
|
||||
index.ts # wires all bundles together
|
||||
```
|
||||
|
||||
## When to Use `pages/` vs `components/`
|
||||
The split is intentional:
|
||||
|
||||
### Use `pages/` for:
|
||||
- **`pages/`** — copy that varies by page context (titles, hero subheads).
|
||||
- **`components/`** — defaults that ride along with a component on every
|
||||
page (aria-labels, alt text patterns).
|
||||
- **`common.json`** — strings reused across many components (e.g. "Cancel",
|
||||
"Learn more").
|
||||
- **`create/`** — wizard step copy (mirrors the `screenId`).
|
||||
|
||||
- **Page-specific content**: Titles, subtitles, descriptions that vary by page
|
||||
- **Context-aware text**: Content that changes based on where the component is used
|
||||
- **User-facing content**: All text that users see on a specific page
|
||||
## Editing existing copy
|
||||
|
||||
**Example:** The home page hero banner title "Collaborate" goes in `pages/home.json`, not `components/heroBanner.json`
|
||||
1. Find the bundle: search `messages/en/` for the existing English string.
|
||||
2. Edit the value. Leave the key alone.
|
||||
3. Save and run `npm run dev` — text updates on reload.
|
||||
|
||||
### Use `components/` for:
|
||||
If you can't find the string, it may still be hard-coded. Open an issue or
|
||||
ping a developer; do not change the component file directly.
|
||||
|
||||
- **Component defaults**: Aria-labels, alt text patterns, shared behavior text
|
||||
- **Shared across pages**: Text that doesn't vary by page context
|
||||
- **Accessibility text**: Aria-labels and alt texts that are component-level
|
||||
## Adding a new key
|
||||
|
||||
**Example:** The hero banner image alt text "Hero illustration" stays in `components/heroBanner.json` because it's the same across all pages
|
||||
|
||||
### Use `common.json` for:
|
||||
|
||||
- **Shared UI strings**: Buttons, links, labels used across multiple components
|
||||
- **Global strings**: Text that appears in many places
|
||||
|
||||
## Page-Specific Translations
|
||||
|
||||
For page-specific content, use the `pages.*` namespace pattern:
|
||||
|
||||
**Server Components:**
|
||||
|
||||
```typescript
|
||||
import messages from "../../messages/en/index";
|
||||
import { getTranslation } from "../../lib/i18n/getTranslation";
|
||||
|
||||
export default function LearnPage() {
|
||||
const t = (key: string) => getTranslation(messages, key);
|
||||
|
||||
const contentLockupData = {
|
||||
title: t("pages.learn.contentLockup.title"),
|
||||
subtitle: t("pages.learn.contentLockup.subtitle"),
|
||||
};
|
||||
|
||||
return <ContentLockup {...contentLockupData} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Client Components:**
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
|
||||
export default function MyComponent() {
|
||||
const t = useTranslation("pages.home.heroBanner");
|
||||
return <h1>{t("title")}</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
## Adding New Translation Keys
|
||||
|
||||
### 1. Identify the Component
|
||||
|
||||
Determine which component needs the translation. If it's a shared string (like a button label), add it to `common.json`. Otherwise, add it to the component-specific file.
|
||||
|
||||
### 2. Add the Key to the JSON File
|
||||
|
||||
Open the appropriate JSON file and add your translation key. Use descriptive, semantic keys:
|
||||
|
||||
**Good:**
|
||||
1. Decide which bundle owns the copy (page vs component vs common).
|
||||
2. Add a descriptive camelCase key. Group related copy in nested objects.
|
||||
3. If you created a new JSON file, register it in `messages/en/index.ts`
|
||||
(a developer will help if you're unsure).
|
||||
4. Reference the key from the component using `useMessages()` (see the
|
||||
localization rule for the snippet).
|
||||
|
||||
```json
|
||||
{
|
||||
"heroBanner": {
|
||||
"title": "Collaborate",
|
||||
"subtitle": "with clarity"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Bad:**
|
||||
|
||||
```json
|
||||
{
|
||||
"text1": "Collaborate",
|
||||
"text2": "with clarity"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Use Nested Objects for Organization
|
||||
|
||||
Group related translations together:
|
||||
|
||||
```json
|
||||
{
|
||||
"numberedCards": {
|
||||
"title": "How CommunityRule works",
|
||||
"buttons": {
|
||||
"createCommunityRule": "Create CommunityRule",
|
||||
"seeHowItWorks": "See how it works"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Update the Component or Page
|
||||
|
||||
**For Page Components (Server Components):**
|
||||
|
||||
```typescript
|
||||
import messages from "../../messages/en/index";
|
||||
import { getTranslation } from "../../lib/i18n/getTranslation";
|
||||
|
||||
export default function MyPage() {
|
||||
const t = (key: string) => getTranslation(messages, key);
|
||||
|
||||
// Use page-specific keys
|
||||
const data = {
|
||||
title: t("pages.home.heroBanner.title"),
|
||||
subtitle: t("pages.home.heroBanner.subtitle"),
|
||||
};
|
||||
|
||||
return <HeroBanner {...data} />;
|
||||
}
|
||||
```
|
||||
|
||||
**For Client Components:**
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
|
||||
export default function MyComponent() {
|
||||
// For page-specific content
|
||||
const t = useTranslation("pages.home.heroBanner");
|
||||
return <h1>{t("title")}</h1>;
|
||||
|
||||
// For component defaults
|
||||
const tDefault = useTranslation("heroBanner");
|
||||
return <img alt={tDefault("imageAlt")} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Translation Key Naming Conventions
|
||||
|
||||
1. **Use camelCase** for keys: `buttonText`, `ariaLabel`
|
||||
2. **Use descriptive names**: `createCommunityRule` not `btn1`
|
||||
3. **Group by component**: Each component has its own namespace
|
||||
4. **Use nested objects** for related strings: `buttons.createCommunityRule`
|
||||
5. **Include context in comments**: Use `_comment` fields for clarity
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"_comment": "HeroBanner component translations",
|
||||
"title": "Collaborate",
|
||||
"subtitle": "with clarity",
|
||||
"description": "Help your community make important decisions..."
|
||||
}
|
||||
```
|
||||
|
||||
## Extracting Strings from Components
|
||||
|
||||
When migrating a component to use translations:
|
||||
|
||||
1. **Identify hardcoded strings** in the component
|
||||
2. **Create translation keys** in the appropriate JSON file
|
||||
3. **Replace hardcoded strings** with `t("key.path")` calls
|
||||
4. **Test the component** to ensure translations load correctly
|
||||
|
||||
### Example Migration
|
||||
|
||||
**Before:**
|
||||
|
||||
```typescript
|
||||
export default function HeroBanner() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Collaborate</h1>
|
||||
<p>with clarity</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function HeroBanner() {
|
||||
const t = useTranslations("heroBanner");
|
||||
return (
|
||||
<div>
|
||||
<h1>{t("title")}</h1>
|
||||
<p>{t("subtitle")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Adding a New Page
|
||||
|
||||
When creating a new page that needs translations:
|
||||
|
||||
1. **Create a page translation file**: `messages/en/pages/about.json` (for example)
|
||||
2. **Add page-specific content**: All user-facing text for that page
|
||||
3. **Import in index.ts**: Add the import and export in `messages/en/index.ts`
|
||||
4. **Use in page component**: Use `t("pages.about.*")` pattern in your page
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
// messages/en/pages/about.json
|
||||
{
|
||||
"_comment": "About page hero copy",
|
||||
"hero": {
|
||||
"title": "About Us",
|
||||
"subtitle": "Learn more about our mission"
|
||||
}
|
||||
}
|
||||
|
||||
// app/about/page.tsx
|
||||
const t = (key: string) => getTranslation(messages, key);
|
||||
const title = t("pages.about.hero.title");
|
||||
```
|
||||
|
||||
## Adding New Languages (Future)
|
||||
|
||||
When adding support for a new language:
|
||||
|
||||
1. **Create a new locale directory**: `messages/es/` (for Spanish, for example)
|
||||
2. **Copy the English files** as a starting point (including `pages/` structure)
|
||||
3. **Translate all strings** in the JSON files
|
||||
4. **Test thoroughly** to ensure all translations are present
|
||||
|
||||
## Testing Translations
|
||||
|
||||
1. **Check for missing keys**: Ensure all translation keys used in components exist in the JSON files
|
||||
2. **Verify type safety**: TypeScript will catch typos in translation keys at compile time
|
||||
3. **Test in browser**: Run the dev server and verify text displays correctly
|
||||
4. **Check for fallbacks**: Missing translations will show the key path (e.g., `heroBanner.title`)
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Content Creators
|
||||
|
||||
- **Edit JSON files directly**: No need to understand React or TypeScript
|
||||
- **Use descriptive comments**: Add `_comment` fields to explain context
|
||||
- **Maintain consistency**: Use the same terminology across components
|
||||
- **Test changes**: Run the dev server to see your changes immediately
|
||||
|
||||
### For Developers
|
||||
|
||||
- **Use TypeScript**: Translation keys are type-safe
|
||||
- **Namespace when possible**: Use `useTranslations("namespace")` for better organization
|
||||
- **Server components first**: Prefer server-side translations for better performance
|
||||
- **Extract incrementally**: Migrate components one at a time
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Buttons and CTAs
|
||||
|
||||
```json
|
||||
{
|
||||
"buttons": {
|
||||
"createCommunityRule": "Create CommunityRule",
|
||||
"seeHowItWorks": "See how it works"
|
||||
"title": "About us",
|
||||
"subtitle": "Why CommunityRule exists"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Aria Labels
|
||||
## Style notes
|
||||
|
||||
```json
|
||||
{
|
||||
"ariaLabels": {
|
||||
"followBluesky": "Follow us on Bluesky",
|
||||
"followGitlab": "Follow us on GitLab"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **camelCase** for structural keys (`compactTitle`, `imageAlt`).
|
||||
- **kebab-case** for content ids that match a URL slug, card id, or step
|
||||
id (`"in-person-meetings"`, `"peer-mediation"`).
|
||||
- Use `_comment` to leave context for the next editor.
|
||||
- Keep terminology consistent — search the messages folder before coining a
|
||||
new label.
|
||||
|
||||
### Dynamic Content
|
||||
## Adding a new language (future)
|
||||
|
||||
For content that varies (like card text), use arrays or numbered keys:
|
||||
|
||||
```json
|
||||
{
|
||||
"cards": {
|
||||
"card1": { "text": "First step" },
|
||||
"card2": { "text": "Second step" },
|
||||
"card3": { "text": "Third step" }
|
||||
}
|
||||
}
|
||||
```
|
||||
1. Create `messages/<locale>/` mirroring `messages/en/`.
|
||||
2. Translate strings; keep keys identical.
|
||||
3. Update `messages/en/index.ts` (or split it per locale) — a developer
|
||||
will wire the locale switcher.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Translation Key Not Found
|
||||
|
||||
If you see a key path like `heroBanner.title` instead of the text:
|
||||
|
||||
1. Check the JSON file exists and has the key
|
||||
2. Verify the key path matches exactly (case-sensitive)
|
||||
3. Restart the dev server if you just added the key
|
||||
|
||||
### TypeScript Errors
|
||||
|
||||
If TypeScript complains about translation keys:
|
||||
|
||||
1. Ensure the key exists in the JSON file
|
||||
2. Check for typos in the key path
|
||||
3. Verify the namespace is correct if using `useTranslations("namespace")`
|
||||
|
||||
### Missing Translations
|
||||
|
||||
If text doesn't appear:
|
||||
|
||||
1. Check the browser console for errors
|
||||
2. Verify the component is wrapped in `MessagesProvider` (for client components)
|
||||
3. Ensure `getTranslation()` is called correctly in server components
|
||||
4. Check if you're using the correct namespace (`pages.*` vs component defaults)
|
||||
|
||||
## Architecture: Hybrid Approach
|
||||
|
||||
This implementation follows the recognized best practice of combining:
|
||||
|
||||
- **Globalized, shared UI elements**: Component defaults in `components/` (aria-labels, alt texts)
|
||||
- **Context-aware, localized content pages**: Page-specific content in `pages/` (titles, descriptions)
|
||||
|
||||
This allows:
|
||||
|
||||
- Components to remain flexible and reusable
|
||||
- Page content to be easily edited without code changes
|
||||
- Clear separation between shared defaults and page-specific content
|
||||
- Scalable structure for adding new pages
|
||||
|
||||
## Resources
|
||||
|
||||
- Component defaults in `messages/en/components/`
|
||||
- Page-specific content in `messages/en/pages/`
|
||||
- Shared UI strings in `messages/en/common.json`
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 2025
|
||||
**Maintained by**: CommunityRule Development Team
|
||||
| Symptom | Likely cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| Key path renders instead of text (e.g. `hero.title`) | Missing key or typo | Check spelling and bundle path |
|
||||
| Copy doesn't update | Dev server cache | Restart `npm run dev` |
|
||||
| TypeScript red squiggle | Bundle not registered | Add the import in `messages/en/index.ts` |
|
||||
|
||||
+15
-15
@@ -78,7 +78,7 @@ Order is preserved here because the columns are positional in the sheets:
|
||||
see §2.4 data-quality issues).
|
||||
- Wizard chip ids are **positional 1..N** within each `messages/en/create/*`
|
||||
array (see `chipRowsFromLabels` in
|
||||
`app/create/screens/select/CommunityStructureSelectScreen.tsx` lines 49–57).
|
||||
`app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx` lines 49–57).
|
||||
The importer should emit a stable lookup table mapping
|
||||
`(facetGroup, label) → wizardChipId` so the recommendation engine can match
|
||||
a user's `selectedXxxIds` against the matrix without depending on label
|
||||
@@ -90,7 +90,7 @@ Order is preserved here because the columns are positional in the sheets:
|
||||
|
||||
Maps 1:1 to `messages/en/create/communication.json` and the
|
||||
`communication-methods` step
|
||||
(`app/create/screens/card/CommunicationMethodsScreen.tsx`).
|
||||
(`app/(app)/create/screens/card/CommunicationMethodsScreen.tsx`).
|
||||
|
||||
**Content columns (positions 1–5):**
|
||||
|
||||
@@ -112,7 +112,7 @@ Matrix / Element · GitHub / GitLab · Discord · Email Distribution List · Sla
|
||||
### 2.3 Membership / Group-Membership (`Group_Membership_Methods.xlsx`, sheet `Current`)
|
||||
|
||||
Maps to the `membership-methods` step
|
||||
(`app/create/screens/card/MembershipMethodsScreen.tsx`) and
|
||||
(`app/(app)/create/screens/card/MembershipMethodsScreen.tsx`) and
|
||||
`messages/en/create/membership.json`.
|
||||
|
||||
**Content columns (positions 1–5):**
|
||||
@@ -142,7 +142,7 @@ Evaluation · Lottery / Sortition.
|
||||
### 2.4 Decision-making (`Decision-making.xlsx`, sheet `Current`)
|
||||
|
||||
Maps to the `decision-approaches` step
|
||||
(`app/create/screens/right-rail/DecisionApproachesScreen.tsx`) and
|
||||
(`app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx`) and
|
||||
`messages/en/create/rightRail.json`.
|
||||
|
||||
**Content columns (positions 1–7):**
|
||||
@@ -182,7 +182,7 @@ Hierarchical Decision-Making · Negotiated Decisions.
|
||||
### 2.5 Conflict Management (`Conflict Management Methods.xlsx`, sheet `Current`)
|
||||
|
||||
Maps to the `conflict-management` step
|
||||
(`app/create/screens/card/ConflictManagementScreen.tsx`) and
|
||||
(`app/(app)/create/screens/card/ConflictManagementScreen.tsx`) and
|
||||
`messages/en/create/conflictManagement.json`.
|
||||
|
||||
**Content columns (positions 1–6):**
|
||||
@@ -251,7 +251,7 @@ function governancePatternBody(coreValues: string): Prisma.InputJsonValue {
|
||||
|
||||
### 3.2 Wizard facets captured today (`CreateFlowState`)
|
||||
|
||||
```83:95:app/create/types.ts
|
||||
```83:95:app/(app)/create/types.ts
|
||||
selectedCommunitySizeIds?: string[];
|
||||
selectedOrganizationTypeIds?: string[];
|
||||
selectedScaleIds?: string[];
|
||||
@@ -275,7 +275,7 @@ the facet shape.
|
||||
|
||||
### 3.3 Wizard step order
|
||||
|
||||
Source of truth is `app/create/utils/flowSteps.ts` (`FLOW_STEP_ORDER`). The
|
||||
Source of truth is `app/(app)/create/utils/flowSteps.ts` (`FLOW_STEP_ORDER`). The
|
||||
relevant slice is:
|
||||
|
||||
```
|
||||
@@ -289,10 +289,10 @@ decision-approaches → conflict-management → confirm-stakeholders → final-r
|
||||
|
||||
| Surface | File |
|
||||
|---|---|
|
||||
| Marketing home "Popular templates" | `app/(marketing)/MarketingRuleStackSection.tsx` |
|
||||
| Marketing home "Popular templates" | `app/(marketing)/_components/MarketingRuleStackSection.tsx` |
|
||||
| Templates index | `app/(marketing)/templates/page.tsx` |
|
||||
| Template preview (by slug) | `app/create/review-template/[slug]/page.tsx` |
|
||||
| "Use without changes" → publish | `app/create/CreateFlowLayoutClient.tsx` `handleUseTemplateWithoutChanges` |
|
||||
| Template preview (by slug) | `app/(app)/create/review-template/[slug]/page.tsx` |
|
||||
| "Use without changes" → publish | `app/(app)/create/CreateFlowLayoutClient.tsx` `handleUseTemplateWithoutChanges` |
|
||||
| API list | `app/api/templates/route.ts` (GET only, no params) |
|
||||
|
||||
There is currently **no** recommendation logic, no facet filtering, and the
|
||||
@@ -697,12 +697,12 @@ Once the API exists:
|
||||
- `lib/server/validation/plainJson.ts` — `assertPlainJsonValue` /
|
||||
`DEFAULT_PLAIN_JSON_LIMITS`.
|
||||
- `lib/logger.ts` — server-side `logger`.
|
||||
- `app/create/types.ts` — `CreateFlowState` and facet fields.
|
||||
- `app/create/utils/flowSteps.ts` — canonical step order.
|
||||
- `app/create/utils/createFlowScreenRegistry.ts` — screen layout per step.
|
||||
- `app/create/screens/select/CommunityStructureSelectScreen.tsx` — chip-id
|
||||
- `app/(app)/create/types.ts` — `CreateFlowState` and facet fields.
|
||||
- `app/(app)/create/utils/flowSteps.ts` — canonical step order.
|
||||
- `app/(app)/create/utils/createFlowScreenRegistry.ts` — screen layout per step.
|
||||
- `app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx` — chip-id
|
||||
derivation pattern (positional `String(i+1)`).
|
||||
- `app/create/screens/card/CommunicationMethodsScreen.tsx` — section-field
|
||||
- `app/(app)/create/screens/card/CommunicationMethodsScreen.tsx` — section-field
|
||||
contract (`SECTION_FIELDS`).
|
||||
- `messages/en/create/{communitySize,communityStructure,communication,membership,rightRail,conflictManagement}.json` —
|
||||
current static card / chip copy that the matrix supersedes.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Testing guide
|
||||
|
||||
This is the **why** of testing in CommunityRule. For file layout, helper
|
||||
APIs, and required imports see `.cursor/rules/testing.mdc`.
|
||||
|
||||
## Philosophy
|
||||
|
||||
- **Test behaviour, not implementation.** Assert on what a user can see and
|
||||
do (visible text, roles, labels, keyboard paths). Avoid leaning on
|
||||
internal class names, hook internals, or memoization details.
|
||||
- **One consolidated file per component.** A component's standard suite,
|
||||
variant assertions, and behaviour-specific cases all live in
|
||||
`tests/components/<Name>.test.tsx`.
|
||||
- **Accessibility is non-negotiable.** Every component suite runs
|
||||
`jest-axe`; full-page WCAG runs in Playwright. A failing axe check is a
|
||||
failing build.
|
||||
- **E2E is sparse and high-signal.** Playwright covers critical journeys,
|
||||
visual regression of major pages, and a handful of edge cases — not
|
||||
per-component clicks. Component coverage is the job of Vitest.
|
||||
|
||||
## Layered coverage
|
||||
|
||||
| Layer | Tool | Scope |
|
||||
| --- | --- | --- |
|
||||
| Unit | Vitest | Pure logic, reducers, utilities (`tests/unit/`). |
|
||||
| Component | Vitest + RTL | DS components in isolation (`tests/components/`). |
|
||||
| Page / context | Vitest + RTL | Screens and provider wiring (`tests/pages/`, `tests/contexts/`). |
|
||||
| Accessibility (page) | Playwright + axe | WCAG 2.1 AA on key pages (`tests/accessibility/e2e/`). |
|
||||
| E2E | Playwright | Critical journeys, visual regression, edge cases (`tests/e2e/`). |
|
||||
|
||||
## What to test vs. skip
|
||||
|
||||
**Test:**
|
||||
|
||||
- Public behaviour — visible text, roles, ARIA, keyboard activation.
|
||||
- State transitions users observe (error → success, disabled → enabled).
|
||||
- Interaction contracts (click handlers, form submission, dropdown
|
||||
selection).
|
||||
- Accessibility invariants.
|
||||
|
||||
**Skip:**
|
||||
|
||||
- Pure styling that changes with the design system (exact shadow radius,
|
||||
minor spacing).
|
||||
- Hook internals or memoization specifics.
|
||||
- Responsive visibility in JSDOM — use Playwright instead.
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
npm test # vitest run with coverage
|
||||
npm run test:component # vitest, components only (faster inner loop)
|
||||
npm run e2e # playwright (alias: test:e2e)
|
||||
npm run visual:update # refresh playwright screenshots after UI changes
|
||||
```
|
||||
|
||||
## Adding tests for a new component
|
||||
|
||||
1. Create `app/components/<Name>/`.
|
||||
2. Create `tests/components/<Name>.test.tsx` and call `componentTestSuite`
|
||||
(see `.cursor/rules/testing.mdc`).
|
||||
3. Add behaviour-specific `describe` blocks for any unique interactions.
|
||||
4. Run `npm run test:component -- --run tests/components/<Name>.test.tsx`.
|
||||
|
||||
## Storybook is documentation, not tests
|
||||
|
||||
Stories are visual examples for design review and Code Connect — they do
|
||||
not replace component tests. Run `npm run storybook` locally to browse.
|
||||
Reference in New Issue
Block a user