admin/eslint.config.mjs
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

316 lines
11 KiB
JavaScript

import { defineConfig } from 'eslint/config'
import nextVitals from 'eslint-config-next/core-web-vitals'
import nextTs from 'eslint-config-next/typescript'
import prettierConfig from 'eslint-config-prettier'
import prettierPlugin from 'eslint-plugin-prettier'
import unusedImportsPlugin from 'eslint-plugin-unused-imports'
import tseslint from 'typescript-eslint'
/**
* Strip plugin registrations from typescript-eslint shared configs so they do not
* collide with eslint-config-next (which already registers @typescript-eslint).
*/
function rulesOnly(configs, files = ['**/*.{ts,tsx}']) {
return configs
.filter((config) => config.rules && Object.keys(config.rules).length > 0)
.map((config) => ({
files: config.files ?? files,
rules: config.rules,
}))
}
/**
* Frontend ESLint — Next.js core-web-vitals + typescript-eslint strictTypeChecked.
*
* Intentional overrides (still a high quality bar):
* - no-unnecessary-condition: off — optional DTO/API fields produce many false positives
* - no-redundant-type-constituents: off — `string | 'literal'` is intentional public API style
* - prefer-nullish-coalescing: ignorePrimitives — flag object nullish only, not `x || 0`
* - react-hooks/set-state-in-effect: off — HeroUI / controlled-input patterns
*/
const eslintConfig = defineConfig([
{
ignores: [
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
'node_modules/**',
'.now/**',
'*.css',
'.changeset/**',
'dist/**',
'esm/**',
'public/**',
'tests/**',
'scripts/**',
'*.config.js',
'*.config.mjs',
'*.config.ts',
'!playwright.config.ts',
'.DS_Store',
'coverage/**',
'playwright-report/**',
'test-results/**',
'api/generated/**',
'orval.config.ts',
'vitest.config.ts',
],
},
...nextVitals,
...nextTs,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
...rulesOnly(tseslint.configs.strictTypeChecked),
...rulesOnly(tseslint.configs.stylisticTypeChecked),
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
prettier: prettierPlugin,
'unused-imports': unusedImportsPlugin,
},
settings: {
react: {
version: 'detect',
},
'import/resolver': {
typescript: {
alwaysTryTypes: true,
},
},
},
rules: {
'no-console': ['error', { allow: ['warn', 'error'] }],
eqeqeq: ['error', 'smart'],
'react/prop-types': 'off',
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
'react-hooks/exhaustive-deps': 'error',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/incompatible-library': 'off',
'react-hooks/preserve-manual-memoization': 'off',
'react/no-unescaped-entities': 'error',
'react/self-closing-comp': 'error',
'jsx-a11y/media-has-caption': 'off',
'jsx-a11y/no-autofocus': 'off',
'jsx-a11y/click-events-have-key-events': 'error',
'jsx-a11y/interactive-supports-focus': 'error',
'prettier/prettier': 'error',
'no-unused-vars': 'off',
'unused-imports/no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'after-used',
ignoreRestSiblings: true,
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unsafe-function-type': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': [
'error',
{ checksVoidReturn: { attributes: false } },
],
'@typescript-eslint/restrict-template-expressions': [
'error',
{ allowNumber: true, allowBoolean: true, allowNullish: true },
],
'@typescript-eslint/prefer-optional-chain': 'error',
'@typescript-eslint/no-unnecessary-condition': 'off',
'@typescript-eslint/no-redundant-type-constituents': 'off',
// Mostly flags intentional Boolean()/Number() coercions at API/UI boundaries.
'@typescript-eslint/no-unnecessary-type-conversion': 'off',
'@typescript-eslint/prefer-nullish-coalescing': [
'error',
{
ignorePrimitives: {
string: true,
number: true,
boolean: true,
bigint: true,
},
},
],
'@typescript-eslint/no-base-to-string': [
'error',
{
ignoredTypeNames: ['Error', 'DOMException', 'URL', 'URLSearchParams', 'RegExp'],
},
],
'@typescript-eslint/no-empty-function': [
'error',
{ allow: ['arrowFunctions', 'functions', 'methods'] },
],
'import/order': [
'error',
{
groups: ['type', 'builtin', 'object', 'external', 'internal', 'parent', 'sibling', 'index'],
pathGroups: [
{
pattern: '@/**',
group: 'internal',
position: 'before',
},
{
pattern: '~/**',
group: 'external',
position: 'after',
},
],
pathGroupsExcludedImportTypes: ['builtin'],
'newlines-between': 'always',
},
],
'react/jsx-sort-props': [
'warn',
{
callbacksLast: true,
shorthandFirst: true,
noSortAlphabetically: false,
reservedFirst: true,
},
],
'padding-line-between-statements': [
'error',
{ blankLine: 'always', prev: '*', next: 'return' },
{ blankLine: 'always', prev: ['const', 'let', 'var'], next: '*' },
{ blankLine: 'any', prev: ['const', 'let', 'var'], next: ['const', 'let', 'var'] },
],
},
},
{
files: ['app/**/*.{js,jsx,ts,tsx}', 'components/**/*.{js,jsx,ts,tsx}', 'features/**/*.{js,jsx,ts,tsx}'],
ignores: ['components/formElements/**', 'components/consumer/input/**', 'components/heroui/**', '**/*.{test,spec}.{js,jsx,ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@/components/heroui/Input',
message: 'Use `@/components/formElements/Input` (supports FormProvider and standalone value/onValueChange).',
},
{
name: '@/components/heroui/Select',
message: 'Use `@/components/formElements/Input` with generalType="select".',
},
{
name: '@/components/heroui/NumberInput',
message: 'Use `@/components/formElements/Input` with generalType="numberInput".',
},
{
name: '@/components/heroui/InputOtp',
message: 'Use `@/components/formElements/Input` with generalType="otp".',
},
{
name: '@/components/heroui/Autocomplete',
message: 'Use `@/components/formElements/Input` with generalType="combobox".',
},
{
name: '@/components/heroui/SelectionControls',
message:
'Use `@/components/formElements/Input` (checkbox/radio/switch) or `@/components/formElements/Checkbox`.',
},
],
},
],
'no-restricted-syntax': [
'error',
{
selector: "JSXOpeningElement[name.name='button']",
message: 'Use `@/components/formElements/Button` instead of raw <button>.',
},
{
selector: "JSXOpeningElement[name.name='textarea']",
message: 'Use `@/components/formElements/Input` with generalType="textarea" instead of raw <textarea>.',
},
{
selector:
"JSXOpeningElement[name.name='input']:not(:has(JSXAttribute[name.name='type'][value.value='file']))",
message:
'Use `@/components/formElements/Input` or `@/components/formElements/Checkbox`. Raw <input> is only allowed for type="file".',
},
],
},
},
{
files: ['components/consumer/ConsumerButton.tsx'],
rules: {
// ConsumerButton is the consumer primitive; it intentionally renders native <button>.
'no-restricted-syntax': [
'error',
{
selector: "JSXOpeningElement[name.name='textarea']",
message: 'Use `@/components/formElements/Input` with generalType="textarea" instead of raw <textarea>.',
},
{
selector:
"JSXOpeningElement[name.name='input']:not(:has(JSXAttribute[name.name='type'][value.value='file']))",
message:
'Use `@/components/formElements/Input` or `@/components/formElements/Checkbox`. Raw <input> is only allowed for type="file".',
},
],
},
},
{
files: ['components/consumer/input/**/*.{ts,tsx}'],
rules: {
// Same as formElements: RHF Controller field values are `any` until forms are zod-inferred.
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
},
},
{
files: ['components/formElements/**/*.{ts,tsx}'],
rules: {
// react-hook-form Controller `field.value` / `field.onChange` are typed as `any`
// until forms are end-to-end zod-inferred. Keep other strict rules on.
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
},
},
{
files: ['**/*.{test,spec}.{ts,tsx}', '**/__tests__/**/*.{ts,tsx}', 'e2e/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-base-to-string': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/no-floating-promises': 'off',
'no-console': 'off',
},
},
prettierConfig,
])
export default eslintConfig