Agent skill
violet-dashboard
VioletDashboard code patterns and conventions
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/development/violet-dashboard-violetio-violet-ai-plugins
SKILL.md
VioletDashboard Code Patterns
Overview
VioletDashboard is a Next.js application serving Channel and Merchant dashboards through subdomain routing.
Tech Stack:
- Next.js 12.x with React 17
- Redux Toolkit with Redux Persist
- SCSS modules
- TypeScript (strict)
ALL patterns in this document are MANDATORY when working on VioletDashboard.
axiosWrapper Pattern (MANDATORY)
ALWAYS use axiosWrapper for ALL HTTP requests. NEVER use fetch or vanilla axios.
Why axiosWrapper?
- Automatic camelCase ↔ snake_case conversion: Client uses camelCase, backend uses snake_case
- Standardized error handling: Consistent error format across application
- Authentication injection: Automatically includes auth headers
- Retry logic: Built-in retry for transient failures
- Type safety: Full TypeScript support with generics
Client-Side vs Server-Side
// Client-side (from browser/component)
const response = await axiosWrapper<PreRegistration[]>(
CONFIG_TYPE.VIOLET_PROXY_API, // Routes through Next.js API routes
{
method: 'GET',
path: 'api/merchants/pre-registrations',
params: { page: 0, size: 20 }
}
);
// Server-side (from API route)
const response = await axiosWrapper<PreRegistration>(
CONFIG_TYPE.VIOLET_API, // Direct to backend
{
method: 'POST',
path: 'merchants/external/pre-register',
data: snakecaseKeys(requestBody),
headers: {
[VIOLET_TOKEN_HEADER]: token
}
}
);
❌ DON'T Use
// ❌ WRONG - Vanilla axios
import axios from 'axios';
const response = await axios.get('/api/merchants/pre-registrations');
// ❌ WRONG - fetch
const response = await fetch('/api/merchants/pre-registrations');
const data = await response.json();
// ❌ WRONG - Direct backend call from client
const response = await axiosWrapper(
CONFIG_TYPE.VIOLET_API, // ❌ Client should use VIOLET_PROXY_API
{ method: 'GET', path: 'merchants/...' }
);
✅ DO Use
// ✅ CORRECT - axiosWrapper with proper CONFIG_TYPE
import axiosWrapper, { CONFIG_TYPE } from '@/utilities/axiosWrapper';
// Client-side: through proxy
const response = await axiosWrapper<PreRegistration[]>(
CONFIG_TYPE.VIOLET_PROXY_API,
{
method: 'GET',
path: 'api/merchants/pre-registrations',
params: { page: 0, size: 20 }
}
);
Redux Thunk Pattern (MANDATORY)
Use createAppAsyncThunk with axiosWrapper. Do NOT use fetch or standard createAsyncThunk.
// redux/thunks/preRegistration.ts
import { createAppAsyncThunk, HasFulfilledMeta, HasRejectedMeta } from 'redux/thunks';
import axiosWrapper, { CONFIG_TYPE } from '@/utilities/axiosWrapper';
import { PreRegistrationActionType } from 'redux/actions/preRegistration';
import { PreRegistration, PreRegistrationListResult } from 'interfaces/preRegistration.interface';
// GET list - rejected meta only
export const getPreRegistrations = createAppAsyncThunk<
PreRegistrationListResult,
{ page?: number; size?: number },
HasRejectedMeta
>(
PreRegistrationActionType.GET_PRE_REGISTRATIONS,
async (params, { rejectWithValue }) => {
try {
const response = await axiosWrapper<PreRegistrationListResult>(
CONFIG_TYPE.VIOLET_PROXY_API,
{
method: 'GET',
path: 'api/merchants/pre-registrations',
params,
}
);
return response.data;
} catch (err: any) {
throw rejectWithValue(err, {
errorMessage: err?.response?.data?.message || 'Failed to load pre-registrations',
});
}
}
);
// POST create - fulfilled + rejected meta for success message
export const createPreRegistration = createAppAsyncThunk<
PreRegistration,
CreatePreRegistrationPayload,
HasFulfilledMeta & HasRejectedMeta
>(
PreRegistrationActionType.CREATE_PRE_REGISTRATION,
async (data, { fulfillWithValue, rejectWithValue }) => {
try {
const response = await axiosWrapper<PreRegistration>(
CONFIG_TYPE.VIOLET_PROXY_API,
{
method: 'POST',
path: 'api/merchants/pre-register',
data,
}
);
return fulfillWithValue(response.data, {
successMessage: 'Pre-registration created',
});
} catch (err: any) {
throw rejectWithValue(err, {
errorMessage: err?.response?.data?.message || 'Failed to create pre-registration',
});
}
}
);
// PUT update - fulfilled + rejected meta
export const updatePreRegistration = createAppAsyncThunk<
PreRegistration,
UpdatePreRegistrationPayload,
HasFulfilledMeta & HasRejectedMeta
>(
PreRegistrationActionType.UPDATE_PRE_REGISTRATION,
async ({ merchantId, ...data }, { fulfillWithValue, rejectWithValue }) => {
try {
const response = await axiosWrapper<PreRegistration>(
CONFIG_TYPE.VIOLET_PROXY_API,
{
method: 'PUT',
path: `api/merchants/pre-register/${merchantId}`,
data,
}
);
return fulfillWithValue(response.data, {
successMessage: 'Pre-registration updated',
});
} catch (err: any) {
throw rejectWithValue(err, {
errorMessage: err?.response?.data?.message || 'Failed to update pre-registration',
});
}
}
);
// DELETE - fulfilled + rejected meta
export const deletePreRegistration = createAppAsyncThunk<
void,
{ merchantId: number },
HasFulfilledMeta & HasRejectedMeta
>(
PreRegistrationActionType.DELETE_PRE_REGISTRATION,
async ({ merchantId }, { fulfillWithValue, rejectWithValue }) => {
try {
await axiosWrapper(CONFIG_TYPE.VIOLET_PROXY_API, {
method: 'DELETE',
path: `api/merchants/pre-register/${merchantId}`,
});
return fulfillWithValue(undefined, {
successMessage: 'Pre-registration deleted',
});
} catch (err: any) {
throw rejectWithValue(err, {
errorMessage: err?.response?.data?.message || 'Failed to delete pre-registration',
});
}
}
);
Key points:
CONFIG_TYPE.VIOLET_PROXY_APIfor client-side calls (through Next.js API routes)CONFIG_TYPE.VIOLET_APIfor server-side calls (direct to backend)rejectWithValue(err, { errorMessage })enables automatic snackbar notificationsfulfillWithValue(data, { successMessage })enables success notifications- DELETE returns
void(no response body expected)
Redux Reducer Pattern (MANDATORY)
Use createReducer with .addCase(). Do NOT use createSlice.
// redux/reducers/preRegistration.ts
import { createReducer } from '@reduxjs/toolkit';
import { PageableResult } from 'interfaces/pageable.interface';
import { PreRegistration } from 'interfaces/preRegistration.interface';
import {
getPreRegistrations,
createPreRegistration,
updatePreRegistration,
deletePreRegistration,
} from '../thunks/preRegistration';
import { clearPreRegistrations } from '../actions/preRegistration';
import withClearableState from '@/redux/reducers/withClearableState';
export interface PreRegistrationState {
list: PreRegistration[];
loading: boolean;
totalElements: number;
totalPages: number;
}
const initialState: PreRegistrationState = {
list: [],
loading: false,
totalElements: 0,
totalPages: 0,
};
const preRegistrationReducer = createReducer(initialState, (builder) => {
builder
// Loading states via thunk.pending (NOT manual dispatch)
.addCase(getPreRegistrations.pending, (state) => {
state.loading = true;
})
// Success states via thunk.fulfilled
.addCase(getPreRegistrations.fulfilled, (state, action) => {
state.loading = false;
state.list = action.payload.content;
state.totalElements = action.payload.totalElements;
state.totalPages = action.payload.totalPages;
})
// Error states via thunk.rejected
.addCase(getPreRegistrations.rejected, (state) => {
state.loading = false;
})
.addCase(createPreRegistration.fulfilled, (state, action) => {
state.list.unshift(action.payload);
state.totalElements += 1;
})
.addCase(updatePreRegistration.fulfilled, (state, action) => {
const index = state.list.findIndex((p) => p.merchantId === action.payload.merchantId);
if (index !== -1) {
state.list[index] = action.payload;
}
})
.addCase(deletePreRegistration.fulfilled, (state, action) => {
state.list = state.list.filter((p) => p.merchantId !== action.meta.arg.merchantId);
state.totalElements -= 1;
})
// Synchronous clear action
.addCase(clearPreRegistrations, () => initialState);
});
// Wrap for automatic cleanup on logout
export default withClearableState(preRegistrationReducer, initialState);
Key points:
- Use
.addCase(thunk.pending)for loading - NOT manualdispatch(startLoading()) - Use
.addCase(thunk.fulfilled)for success - Use
.addCase(thunk.rejected)for errors - Wrap with
withClearableStatefor logout cleanup - State mutations are allowed (immer handles immutability)
Redux Actions Pattern
Action types and synchronous actions only. Thunks go in redux/thunks/.
// redux/actions/preRegistration.ts
import { createAction } from '@reduxjs/toolkit';
// Action type enum
export enum PreRegistrationActionType {
GET_PRE_REGISTRATIONS = 'GET_PRE_REGISTRATIONS',
CREATE_PRE_REGISTRATION = 'CREATE_PRE_REGISTRATION',
UPDATE_PRE_REGISTRATION = 'UPDATE_PRE_REGISTRATION',
DELETE_PRE_REGISTRATION = 'DELETE_PRE_REGISTRATION',
CLEAR_PRE_REGISTRATIONS = 'CLEAR_PRE_REGISTRATIONS',
}
// Payload types namespace
export namespace PayloadType {
export interface CreatePreRegistration {
merchantName: string;
storeUrl: string;
clientId: string;
secret: string;
installLink: string;
}
export interface UpdatePreRegistration {
merchantId: number;
merchantName?: string;
clientId?: string;
secret?: string;
}
}
// Synchronous actions only
export const clearPreRegistrations = createAction(
PreRegistrationActionType.CLEAR_PRE_REGISTRATIONS
);
API Route Pattern (MANDATORY)
Use nextConnectConfiguration() with axiosWrapper. Do NOT use custom middleware or fetch.
// pages/api/merchants/pre-register/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import snakecaseKeys from 'snakecase-keys';
import { NextHandler } from 'next-connect';
import { VIOLET_TOKEN_HEADER } from '@violet/shared-types/constants/headers';
import axiosWrapper, { CONFIG_TYPE } from '@/utilities/axiosWrapper';
import { getTokenWithDecodedData } from '@/utilities/auth';
import { nextConnectConfiguration } from '@/middlewares/globalApiMiddleware';
const apiRoute = nextConnectConfiguration();
// GET handler
apiRoute.get(
async (req: NextApiRequest, res: NextApiResponse, next: NextHandler) => {
try {
const tokenWithDecodedData = getTokenWithDecodedData(req, res);
const { id } = req.query;
const result = await axiosWrapper(CONFIG_TYPE.VIOLET_API, {
method: 'GET',
path: `merchants/external/pre-register/${id}`,
headers: {
[VIOLET_TOKEN_HEADER]: tokenWithDecodedData.token,
},
});
res.status(result.status).json(result.data);
} catch (err: any) {
next(err); // Pass to central error handler
}
}
);
// PUT handler
apiRoute.put(
async (req: NextApiRequest, res: NextApiResponse, next: NextHandler) => {
try {
const tokenWithDecodedData = getTokenWithDecodedData(req, res);
const { id } = req.query;
const result = await axiosWrapper(CONFIG_TYPE.VIOLET_API, {
method: 'PUT',
path: `merchants/external/pre-register/${id}`,
headers: {
[VIOLET_TOKEN_HEADER]: tokenWithDecodedData.token,
},
data: snakecaseKeys(req.body),
});
res.status(result.status).json(result.data);
} catch (err: any) {
next(err);
}
}
);
// DELETE handler
apiRoute.delete(
async (req: NextApiRequest, res: NextApiResponse, next: NextHandler) => {
try {
const tokenWithDecodedData = getTokenWithDecodedData(req, res);
const { id } = req.query;
const result = await axiosWrapper(CONFIG_TYPE.VIOLET_API, {
method: 'DELETE',
path: `merchants/external/pre-register/${id}`,
headers: {
[VIOLET_TOKEN_HEADER]: tokenWithDecodedData.token,
},
});
res.status(result.status).json(result.data);
} catch (err: any) {
next(err);
}
}
);
export default apiRoute;
Key points:
nextConnectConfiguration()includes Helmet, CORS, auth middlewaregetTokenWithDecodedData(req, res)extracts auth from cookiessnakecaseKeys(req.body)converts to backend format- Pass errors to
next(err)- NOT manualres.status(500).json() - Response automatically converts snake_case to camelCase
API Layer Constraints (MANDATORY)
Next.js API routes in VioletDashboard MUST be lightweight proxies to the backend. NO business logic.
This is the established architecture: "the whole reason the dashboard is structured the way it is."
❌ DON'T Add Business Logic
// ❌ WRONG - Business logic in API route
export default async (req, res) => {
const { id } = req.query;
// ❌ NO - Data transformation
const result = await axiosWrapper(CONFIG_TYPE.VIOLET_API, {
method: 'GET',
path: `merchants/external/pre-register/${id}`
});
// ❌ NO - Business logic here
const transformed = {
...result.data,
displayName: result.data.merchant_name,
isActive: result.data.status === 'active',
daysOld: calculateDaysOld(result.data.date_created)
};
res.status(200).json(transformed);
};
✅ DO Keep It Lightweight
// ✅ CORRECT - Lightweight proxy
apiRoute.get(
async (req: NextApiRequest, res: NextApiResponse, next: NextHandler) => {
try {
const tokenWithDecodedData = getTokenWithDecodedData(req, res);
const { id } = req.query;
// Pass through to backend - no transformation
const result = await axiosWrapper(CONFIG_TYPE.VIOLET_API, {
method: 'GET',
path: `merchants/external/pre-register/${id}`,
headers: {
[VIOLET_TOKEN_HEADER]: tokenWithDecodedData.token,
},
});
// Pass through response directly (camelCase conversion automatic)
res.status(result.status).json(result.data);
} catch (err: any) {
next(err); // Pass to central error handler
}
}
);
What API Routes Should Do
✅ DO:
- Authenticate requests (via
getTokenWithDecodedData) - Proxy requests to backend
- Convert camelCase ↔ snake_case (via
axiosWrapper+snakecaseKeys) - Pass through responses
- Handle errors via
next(err)
❌ DON'T:
- Transform data formats
- Add computed fields
- Filter or sort data
- Implement business rules
- Cache responses (unless justified exception)
Where Business Logic Belongs
| Logic Type | Location |
|---|---|
| Data transformation | Client (components, selectors) |
| Business rules | Backend services |
| Computed values | Client (selectors, useMemo) |
| Filtering/sorting | Client (components) or backend (query params) |
| Validation | Backend (primary) + client (UX only) |
Exceptions
The ONLY exceptions to lightweight proxy pattern:
- User management (existing pattern)
- Session handling (existing pattern)
- Explicit architectural justification documented in ADR
If you're unsure, default to lightweight proxy.
Interface Organization
- Shared interfaces go in
interfaces/*.ts - Component props go INLINE in the component file
// interfaces/preRegistration.interface.ts
import { PageableResult } from './pageable.interface';
export interface PreRegistration {
merchantId: number;
merchantName: string;
storeUrl: string;
status: PreRegistrationStatus;
dateCreated: string;
}
export enum PreRegistrationStatus {
PENDING = 'PENDING',
ACTIVE = 'ACTIVE',
FOR_DELETION = 'FOR_DELETION',
EXPIRED = 'EXPIRED',
}
export type PreRegistrationListResult = PageableResult<PreRegistration>;
// components/merchants/PreRegTable.tsx - Props inline
interface PreRegTableProps {
data: PreRegistration[];
loading: boolean;
onEdit: (item: PreRegistration) => void;
onDelete: (merchantId: number) => void;
}
export const PreRegTable: React.FC<PreRegTableProps> = ({ data, loading, onEdit, onDelete }) => {
// Component implementation
};
UI Component Policy: NO MATERIAL-UI (CRITICAL)
VioletDashboard has STOPPED using Material-UI (MUI). Do NOT create new components with MUI.
Why No MUI?
- Design System Mismatch: MUI creates problems matching VioletDashboard's custom design system
- Visual Inconsistency: MUI components look different from existing designs
- Migration Complete: VioletDashboard uses custom components with SCSS modules and CSS variables
Instead of MUI, Use:
| DON'T Use (MUI) | DO Use (Custom Design System) |
|---|---|
<Tabs>, <Tab> |
Custom tabs with <button> + SCSS |
<Table>, <TableRow>, <TableCell> |
<div> with flexbox + SCSS modules |
<Dialog>, <DialogTitle> |
Custom modal with <div> + SCSS |
<TextField>, <Input> |
Custom input components |
<Button> |
<button> with SCSS (or @/components/Button) |
<IconButton>, <Tooltip> |
<button> with SCSS |
<Chip> |
<span> with SCSS classes |
<CircularProgress> |
<Spinner> component |
@mui/icons-material/* |
Figma SVG exports from public/images/svg/ |
Design System Patterns
CSS Variables (use these):
--primary-light // Primary color
--secondary-color // Borders, backgrounds
--text-color-tertiary // Muted text
--button-background-color // Button fills
--border-default // Standard borders
--light-gray // Text color
SCSS Modules:
// MyComponent.module.scss
.container {
background: var(--secondary-color);
border: 1px solid var(--border-default);
}
.button {
background: var(--button-background-color);
color: var(--text-color);
&:hover {
background: var(--button-background-color-hover);
}
}
Icons:
// DON'T: Material-UI icons
import { ContentCopy, MoreVert } from '@mui/icons-material';
// DO: Figma SVG exports
import CopyIcon from '@/public/images/svg/copy.svg';
import ThreeDotsIcon from '@/public/images/svg/three-dots.svg';
// Usage
<button className={styles.iconButton}>
<CopyIcon className={styles.icon} />
</button>
Example: Tabs Without MUI
// Custom tab navigation
const MerchantsTabNavigation = ({ activeTab, onTabChange }) => (
<div className={styles.tabContainer}>
<div className={styles.tabs}>
<button
className={`${styles.tab} ${activeTab === 'connected' ? styles.active : ''}`}
onClick={() => onTabChange('connected')}
>
Connected Merchants
</button>
<button
className={`${styles.tab} ${activeTab === 'pre-registered' ? styles.active : ''}`}
onClick={() => onTabChange('pre-registered')}
>
Pre-Registered
</button>
</div>
</div>
);
// MerchantsTabNavigation.module.scss
.tabContainer {
border-bottom: 2px solid var(--secondary-color);
}
.tabs {
display: flex;
}
.tab {
background: none;
border: none;
cursor: pointer;
padding: 12px 24px;
font-size: 14px;
font-weight: 500;
color: var(--text-color-tertiary);
&:hover {
color: var(--primary-light);
}
&.active {
color: var(--primary-light);
&::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background: var(--button-background-color);
}
}
}
Anti-Patterns (DO NOT USE)
| Wrong | Correct |
|---|---|
@mui/material imports |
Custom components with SCSS modules |
@mui/icons-material icons |
Figma SVG exports from public/images/svg/ |
fetch('/api/...') |
axiosWrapper(CONFIG_TYPE.VIOLET_PROXY_API, {...}) |
createSlice({...}) |
createReducer(initialState, (builder) => {...}) |
createAsyncThunk() |
createAppAsyncThunk<R, P, M>() |
dispatch(setLoading(true)) |
.addCase(thunk.pending, ...) in reducer |
Props in interfaces/ |
Props inline in component file |
| Manual CORS/Helmet | nextConnectConfiguration() |
res.status(500).json({...}) |
next(err) in API routes |
console.log() in commits |
Remove before PR |
Path Aliases
Always use path aliases, not relative imports:
// Correct
import { Button } from '@/components/Button';
import { useAuth } from '@/hooks/useAuth';
import axiosWrapper from '@/utilities/axiosWrapper';
// Wrong
import { Button } from '../../../components/Button';
Component Location
| Type | Location |
|---|---|
| Reusable UI components | components/ |
| Page-specific components | pageComponents/ |
| Custom hooks | hooks/ |
| Utilities | utilities/ |
| Shared interfaces | interfaces/ |
| Redux | redux/actions/, redux/reducers/, redux/thunks/ |
Didn't find tool you were looking for?