From 981b415430942d6c220ef3929368c275f7bb72c1 Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 26 Mar 2026 08:54:44 +0800 Subject: [PATCH] design: apply Pixel design system tokens (Fira Sans/Code, CSS vars, badge helpers); add Playwright E2E setup + auth spec --- .gitignore | 5 ++ app/globals.css | 128 ++++++++++++++++++++++++++++--------------- app/layout.tsx | 22 +++++--- e2e/auth.spec.ts | 39 +++++++++++++ e2e/helpers/auth.ts | 16 ++++++ package-lock.json | 64 ++++++++++++++++++++++ package.json | 1 + playwright.config.ts | 21 +++++++ 8 files changed, 243 insertions(+), 53 deletions(-) create mode 100644 e2e/auth.spec.ts create mode 100644 e2e/helpers/auth.ts create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index fd3dbb5..8fda23c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,8 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Playwright +playwright-report/ +test-results/ +.auth/ diff --git a/app/globals.css b/app/globals.css index 1ebba8f..218c15d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,63 +2,101 @@ @tailwind components; @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 { :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --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; - } + /* FiberOps tokens */ + --color-primary: #0891B2; + --color-primary-dark: #0E7490; + --color-secondary: #22D3EE; + --color-cta: #059669; + --color-cta-hover: #047857; + --color-bg: #F8FAFC; + --color-surface: #FFFFFF; + --color-text: #0F172A; + --color-muted: #64748B; + --color-border: #E2E8F0; + --color-danger: #EF4444; + --color-warning: #F59E0B; + --color-success: #059669; - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --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%; + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); + --shadow-md: 0 4px 6px rgba(0,0,0,0.07); + --shadow-lg: 0 10px 15px rgba(0,0,0,0.08); + --shadow-xl: 0 20px 25px rgba(0,0,0,0.12); + + /* Radius */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; } * { - @apply border-border; + box-sizing: border-box; } 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 { - .text-balance { - text-wrap: balance; + /* Skeleton loading animation */ + .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; } } diff --git a/app/layout.tsx b/app/layout.tsx index 80963ac..bea5b62 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,23 +1,29 @@ import type { Metadata } from 'next'; -import { Inter } from 'next/font/google'; +import { Fira_Sans, Fira_Code } from 'next/font/google'; import './globals.css'; 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 = { title: 'FiberOps Admin', description: 'ISP Management Platform', }; -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..ccdce46 --- /dev/null +++ b/e2e/auth.spec.ts @@ -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/); + }); +}); diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..c71e222 --- /dev/null +++ b/e2e/helpers/auth.ts @@ -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 }); +} diff --git a/package-lock.json b/package-lock.json index 8668e7c..959d9e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "zustand": "^5.0.12" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -1547,6 +1548,22 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -8376,6 +8393,53 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 3901452..2dae793 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "zustand": "^5.0.12" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..bf5a2aa --- /dev/null +++ b/playwright.config.ts @@ -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 }, + }, + ], +});