design: apply Pixel design system tokens (Fira Sans/Code, CSS vars, badge helpers); add Playwright E2E setup + auth spec
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -34,3 +34,8 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
.auth/
|
||||||
|
|||||||
128
app/globals.css
128
app/globals.css
@@ -2,63 +2,101 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* FiberOps Design System — Pixel/Conan approved */
|
||||||
|
/* Primary: #0891B2 | CTA: #059669 | BG: #F8FAFC | Text: #0F172A */
|
||||||
|
/* Fonts: Fira Sans (body) + Fira Code (data/numbers) */
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
/* FiberOps tokens */
|
||||||
--foreground: 222.2 84% 4.9%;
|
--color-primary: #0891B2;
|
||||||
--card: 0 0% 100%;
|
--color-primary-dark: #0E7490;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--color-secondary: #22D3EE;
|
||||||
--popover: 0 0% 100%;
|
--color-cta: #059669;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--color-cta-hover: #047857;
|
||||||
--primary: 222.2 47.4% 11.2%;
|
--color-bg: #F8FAFC;
|
||||||
--primary-foreground: 210 40% 98%;
|
--color-surface: #FFFFFF;
|
||||||
--secondary: 210 40% 96.1%;
|
--color-text: #0F172A;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--color-muted: #64748B;
|
||||||
--muted: 210 40% 96.1%;
|
--color-border: #E2E8F0;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--color-danger: #EF4444;
|
||||||
--accent: 210 40% 96.1%;
|
--color-warning: #F59E0B;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--color-success: #059669;
|
||||||
--destructive: 0 84.2% 60.2%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
|
||||||
--input: 214.3 31.8% 91.4%;
|
|
||||||
--ring: 222.2 84% 4.9%;
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
/* Shadows */
|
||||||
--background: 222.2 84% 4.9%;
|
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
||||||
--foreground: 210 40% 98%;
|
--shadow-md: 0 4px 6px rgba(0,0,0,0.07);
|
||||||
--card: 222.2 84% 4.9%;
|
--shadow-lg: 0 10px 15px rgba(0,0,0,0.08);
|
||||||
--card-foreground: 210 40% 98%;
|
--shadow-xl: 0 20px 25px rgba(0,0,0,0.12);
|
||||||
--popover: 222.2 84% 4.9%;
|
|
||||||
--popover-foreground: 210 40% 98%;
|
/* Radius */
|
||||||
--primary: 210 40% 98%;
|
--radius-sm: 6px;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--radius-md: 10px;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--radius-lg: 14px;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--radius-xl: 20px;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
|
||||||
--accent: 217.2 32.6% 17.5%;
|
|
||||||
--accent-foreground: 210 40% 98%;
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
|
||||||
--input: 217.2 32.6% 17.5%;
|
|
||||||
--ring: 212.7 26.8% 83.9%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: 'Fira Sans', 'Inter', system-ui, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Data values, numbers, IDs */
|
||||||
|
code, .font-mono, .tabular-nums {
|
||||||
|
font-family: 'Fira Code', 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.text-balance {
|
/* Skeleton loading animation */
|
||||||
text-wrap: balance;
|
.skeleton {
|
||||||
|
@apply animate-pulse bg-slate-200 rounded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Row hover for tables */
|
||||||
|
.table-row-hover:hover {
|
||||||
|
background-color: #F0FDFF;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition helper */
|
||||||
|
.transition-fast {
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-smooth {
|
||||||
|
transition: all 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card base */
|
||||||
|
.fiberops-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badge helpers */
|
||||||
|
.badge-active { background: #DCFCE7; color: #166534; }
|
||||||
|
.badge-inactive { background: #F1F5F9; color: #475569; }
|
||||||
|
.badge-suspended { background: #FEF3C7; color: #92400E; }
|
||||||
|
.badge-pending { background: #DBEAFE; color: #1E40AF; }
|
||||||
|
.badge-overdue { background: #FEE2E2; color: #991B1B; }
|
||||||
|
.badge-paid { background: #DCFCE7; color: #166534; }
|
||||||
|
.badge-partial { background: #FEF3C7; color: #92400E; }
|
||||||
|
.badge-void { background: #F1F5F9; color: #94A3B8; }
|
||||||
|
.badge-open { background: #DBEAFE; color: #1E40AF; }
|
||||||
|
.badge-in-progress { background: #FEF3C7; color: #92400E; }
|
||||||
|
.badge-resolved { background: #DCFCE7; color: #166534; }
|
||||||
|
.badge-closed { background: #F1F5F9; color: #475569; }
|
||||||
|
|
||||||
|
.text-balance { text-wrap: balance; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Fira_Sans, Fira_Code } from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import Providers from '@/components/providers';
|
import Providers from '@/components/providers';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
const firaSans = Fira_Sans({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['300', '400', '500', '600', '700'],
|
||||||
|
variable: '--font-fira-sans',
|
||||||
|
});
|
||||||
|
|
||||||
|
const firaCode = Fira_Code({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['400', '500', '600', '700'],
|
||||||
|
variable: '--font-fira-code',
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'FiberOps Admin',
|
title: 'FiberOps Admin',
|
||||||
description: 'ISP Management Platform',
|
description: 'ISP Management Platform',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={inter.className}>
|
<body className={`${firaSans.variable} ${firaCode.variable} ${firaSans.className}`}>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
39
e2e/auth.spec.ts
Normal file
39
e2e/auth.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login, DEMO } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('Authentication', () => {
|
||||||
|
test('login page renders correctly', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page.locator('text=FiberOps')).toBeVisible();
|
||||||
|
await expect(page.locator('text=Sign in to your account')).toBeVisible();
|
||||||
|
await expect(page.locator('input[placeholder="e.g. demo-isp"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[type="email"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[type="password"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login with valid credentials redirects to dashboard', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login with wrong credentials shows error', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('input[placeholder="e.g. demo-isp"]', DEMO.tenant);
|
||||||
|
await page.fill('input[type="email"]', DEMO.email);
|
||||||
|
await page.fill('input[type="password"]', 'wrongpassword');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
// Should show error toast or stay on login
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unauthenticated redirect to login', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logout returns to login', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.click('button:has-text("Logout"), a:has-text("Logout")');
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
});
|
||||||
16
e2e/helpers/auth.ts
Normal file
16
e2e/helpers/auth.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export const DEMO = {
|
||||||
|
tenant: 'demo-isp',
|
||||||
|
email: 'admin@demo-isp.com',
|
||||||
|
password: 'Admin123!',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function login(page: Page, creds = DEMO) {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('input[placeholder="e.g. demo-isp"]', creds.tenant);
|
||||||
|
await page.fill('input[type="email"]', creds.email);
|
||||||
|
await page.fill('input[type="password"]', creds.password);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForURL('**/dashboard', { timeout: 10000 });
|
||||||
|
}
|
||||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -38,6 +38,7 @@
|
|||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
@@ -1547,6 +1548,22 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/number": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
@@ -8376,6 +8393,53 @@
|
|||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
|||||||
21
playwright.config.ts
Normal file
21
playwright.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: false, // run serially — shared demo tenant
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: 1,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['html', { outputFolder: 'playwright-report' }], ['line']],
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.E2E_BASE_URL || 'http://192.168.1.167:3002',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'], headless: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user