diff --git a/.agents/skills/Amazon-ad-console/SKILL.md b/.agents/skills/Amazon-ad-console/SKILL.md deleted file mode 100644 index 3f424e6..0000000 --- a/.agents/skills/Amazon-ad-console/SKILL.md +++ /dev/null @@ -1,136 +0,0 @@ -```markdown -# Amazon-ad-console Development Patterns - -> Auto-generated skill from repository analysis - -## Overview - -This skill teaches you the core development patterns, coding conventions, and workflows used in the `Amazon-ad-console` TypeScript codebase. You'll learn how to follow the repository's conventions for file naming, imports/exports, commit messages, and testing. You'll also get step-by-step guidance for common bugfix workflows, including how to ensure all fixes are properly tested and committed. - -## Coding Conventions - -### File Naming - -- **Files:** Use `camelCase` for file names. - - Example: `adEngine.ts`, `adConsoleStore.ts` - -### Import Style - -- **Mixed imports:** Both default and named imports are used as appropriate. - - Example: - ```typescript - import React from 'react'; - import { fetchAds, updateAd } from './adEngine'; - ``` - -### Export Style - -- **Named exports** are preferred. - - Example: - ```typescript - // adEngine.ts - export function fetchAds() { /* ... */ } - export function updateAd() { /* ... */ } - ``` - -### Commit Messages - -- **Conventional commits** are used. -- Prefixes like `fix` are common. -- Messages are concise (~63 characters on average). - - Example: `fix: correct ad targeting logic in engine` - -## Workflows - -### Engine Bugfix with Test - -**Trigger:** When you discover a bug in the core logic or feature engine and want to fix it and ensure it doesn't regress. -**Command:** `/engine-bugfix` - -1. **Identify and fix the bug** in the relevant engine or feature file: - - `src/engine/ad-console/core/engine/*.ts` - - `src/engine/ad-console/features/*/engine.ts` - - `src/engine/ad-console/features/*/store.ts` - - `src/engine/ad-console/core/slices/*.ts` -2. **Add or update a test** in the corresponding `__tests__` directory to cover the fixed behavior: - - `src/engine/ad-console/core/__tests__/*.test.ts` - - `src/engine/ad-console/features/*/__tests__/*.test.ts` -3. **Commit both the implementation and the test together.** - - Example commit message: `fix: handle edge case in ad budget calculation` - -**Example:** -```typescript -// src/engine/ad-console/core/engine/adBudget.ts -export function calculateBudget(ad) { - // fixed logic here -} - -// src/engine/ad-console/core/__tests__/adBudget.test.ts -import { calculateBudget } from '../engine/adBudget'; -import { describe, it, expect } from 'vitest'; - -describe('calculateBudget', () => { - it('handles zero budget', () => { - expect(calculateBudget({ budget: 0 })).toBe(0); - }); -}); -``` - ---- - -### API Route Bugfix with Test - -**Trigger:** When you need to fix a bug in an API endpoint's logic and ensure correct behavior with a test. -**Command:** `/api-bugfix` - -1. **Fix the bug** in the relevant API route file: - - `src/app/api/*/route.ts` -2. **Add or update a test** in the corresponding `__tests__` directory for that route: - - `src/app/api/*/__tests__/route.test.ts` -3. **Commit both the route and its test together.** - - Example commit message: `fix: correct response for ad stats API` - -**Example:** -```typescript -// src/app/api/stats/route.ts -export function getAdStats(req, res) { - // fixed API logic here -} - -// src/app/api/stats/__tests__/route.test.ts -import { getAdStats } from '../route'; -import { describe, it, expect } from 'vitest'; - -describe('getAdStats', () => { - it('returns correct stats for valid ad', () => { - // test logic here - }); -}); -``` - -## Testing Patterns - -- **Framework:** [vitest](https://vitest.dev/) -- **Test files:** Use the pattern `*.test.ts` and are located in `__tests__` directories adjacent to the code. -- **Test structure:** Use `describe`, `it`, and `expect` for organizing and writing tests. - -**Example:** -```typescript -// src/engine/ad-console/core/__tests__/adEngine.test.ts -import { someFunction } from '../engine/adEngine'; -import { describe, it, expect } from 'vitest'; - -describe('someFunction', () => { - it('returns expected result', () => { - expect(someFunction()).toBe('expected'); - }); -}); -``` - -## Commands - -| Command | Purpose | -|-----------------|-------------------------------------------------------| -| /engine-bugfix | Fix a bug in engine/feature logic and add a test | -| /api-bugfix | Fix a bug in an API route and add a test | -``` diff --git a/.agents/skills/Amazon-ad-console/agents/openai.yaml b/.agents/skills/Amazon-ad-console/agents/openai.yaml deleted file mode 100644 index c6a4df7..0000000 --- a/.agents/skills/Amazon-ad-console/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Amazon Ad Console" - short_description: "Repo-specific patterns and workflows for Amazon-ad-console" - default_prompt: "Use the Amazon-ad-console repo skill to follow existing architecture, testing, and workflow conventions." -policy: - allow_implicit_invocation: true \ No newline at end of file diff --git a/.claude/commands/api-route-bugfix-with-test.md b/.claude/commands/api-route-bugfix-with-test.md deleted file mode 100644 index 619f742..0000000 --- a/.claude/commands/api-route-bugfix-with-test.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: api-route-bugfix-with-test -description: Workflow command scaffold for api-route-bugfix-with-test in Amazon-ad-console. -allowed_tools: ["Bash", "Read", "Write", "Grep", "Glob"] ---- - -# /api-route-bugfix-with-test - -Use this workflow when working on **api-route-bugfix-with-test** in `Amazon-ad-console`. - -## Goal - -Fixes a bug in an API route handler and adds or updates a test to verify the fix. - -## Common Files - -- `src/app/api/*/route.ts` -- `src/app/api/*/__tests__/route.test.ts` - -## Suggested Sequence - -1. Understand the current state and failure mode before editing. -2. Make the smallest coherent change that satisfies the workflow goal. -3. Run the most relevant verification for touched files. -4. Summarize what changed and what still needs review. - -## Typical Commit Signals - -- Fix the bug in the relevant API route file (e.g., /api/*/route.ts). -- Add or update a test in the corresponding __tests__ directory for that route. -- Commit both the route and its test together. - -## Notes - -- Treat this as a scaffold, not a hard-coded script. -- Update the command if the workflow evolves materially. \ No newline at end of file diff --git a/.claude/commands/engine-bugfix-with-test.md b/.claude/commands/engine-bugfix-with-test.md deleted file mode 100644 index f4ea94e..0000000 --- a/.claude/commands/engine-bugfix-with-test.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: engine-bugfix-with-test -description: Workflow command scaffold for engine-bugfix-with-test in Amazon-ad-console. -allowed_tools: ["Bash", "Read", "Write", "Grep", "Glob"] ---- - -# /engine-bugfix-with-test - -Use this workflow when working on **engine-bugfix-with-test** in `Amazon-ad-console`. - -## Goal - -Fixes a bug in a core engine or feature logic file and adds or updates a corresponding test to cover the fixed behavior. - -## Common Files - -- `src/engine/ad-console/core/engine/*.ts` -- `src/engine/ad-console/features/*/engine.ts` -- `src/engine/ad-console/features/*/store.ts` -- `src/engine/ad-console/core/slices/*.ts` -- `src/engine/ad-console/core/__tests__/*.test.ts` -- `src/engine/ad-console/features/*/__tests__/*.test.ts` - -## Suggested Sequence - -1. Understand the current state and failure mode before editing. -2. Make the smallest coherent change that satisfies the workflow goal. -3. Run the most relevant verification for touched files. -4. Summarize what changed and what still needs review. - -## Typical Commit Signals - -- Identify and fix the bug in the relevant engine or feature file (e.g., core/engine/*.ts, features/*/engine.ts, features/*/store.ts). -- Add or update a test in the corresponding __tests__ directory to cover the fixed behavior. -- Commit both the implementation and the test together. - -## Notes - -- Treat this as a scaffold, not a hard-coded script. -- Update the command if the workflow evolves materially. \ No newline at end of file diff --git a/.claude/ecc-tools.json b/.claude/ecc-tools.json deleted file mode 100644 index f9c61ce..0000000 --- a/.claude/ecc-tools.json +++ /dev/null @@ -1,273 +0,0 @@ -{ - "version": "1.3", - "schemaVersion": "1.0", - "generatedBy": "ecc-tools", - "generatedAt": "2026-08-03T07:48:46.391Z", - "repo": "https://github.com/projectamazonph/Amazon-ad-console", - "referenceSetReadiness": { - "score": 0, - "present": 0, - "total": 7, - "items": [ - { - "id": "deep-analyzer-corpus", - "label": "Deep analyzer corpus", - "status": "missing", - "evidence": [], - "recommendation": "Add analyzer fixture, golden, benchmark, or reference-set files that can catch analyzer regressions." - }, - { - "id": "rag-evaluator", - "label": "RAG/evaluator comparison", - "status": "missing", - "evidence": [], - "recommendation": "Add retrieval or evaluator reference-set comparison fixtures with expected ranking behavior." - }, - { - "id": "pr-salvage", - "label": "PR salvage/review corpus", - "status": "missing", - "evidence": [], - "recommendation": "Add stale-PR, review-thread, reopen-flow, or salvage reference cases for queue cleanup automation." - }, - { - "id": "discussion-triage", - "label": "Discussion triage corpus", - "status": "missing", - "evidence": [], - "recommendation": "Add public discussion triage fixtures, golden cases, or reference sets for informational, answered, and no-response classifications." - }, - { - "id": "harness-compatibility", - "label": "Harness compatibility", - "status": "missing", - "evidence": [], - "recommendation": "Add cross-harness, adapter-compliance, or harness-audit evidence for Claude, Codex, OpenCode, Zed, dmux, and agent surfaces." - }, - { - "id": "security-evidence", - "label": "Security evidence", - "status": "missing", - "evidence": [], - "recommendation": "Attach security evidence such as SBOMs, SARIF, audit reports, or AgentShield evidence packs." - }, - { - "id": "ci-failure-mode", - "label": "CI failure-mode evidence", - "status": "missing", - "evidence": [], - "recommendation": "Add captured CI failure logs, dry-run fixtures, or troubleshooting docs for common workflow failure modes." - } - ] - }, - "profiles": { - "requested": "developer", - "recommended": "developer", - "effective": "developer", - "requestedAlias": "developer", - "recommendedAlias": "developer", - "effectiveAlias": "developer" - }, - "requestedProfile": "developer", - "profile": "developer", - "recommendedProfile": "developer", - "effectiveProfile": "developer", - "tier": "free", - "requestedComponents": [ - "repo-baseline", - "workflow-automation" - ], - "selectedComponents": [ - "repo-baseline", - "workflow-automation" - ], - "requestedAddComponents": [], - "requestedRemoveComponents": [], - "blockedRemovalComponents": [], - "tierFilteredComponents": [], - "requestedRootPackages": [ - "runtime-core", - "workflow-pack" - ], - "selectedRootPackages": [ - "runtime-core", - "workflow-pack" - ], - "requestedPackages": [ - "runtime-core", - "workflow-pack" - ], - "requestedAddPackages": [], - "requestedRemovePackages": [], - "selectedPackages": [ - "runtime-core", - "workflow-pack" - ], - "packages": [ - "runtime-core", - "workflow-pack" - ], - "blockedRemovalPackages": [], - "tierFilteredRootPackages": [], - "tierFilteredPackages": [], - "conflictingPackages": [], - "dependencyGraph": { - "runtime-core": [], - "workflow-pack": [ - "runtime-core" - ] - }, - "resolutionOrder": [ - "runtime-core", - "workflow-pack" - ], - "requestedModules": [ - "runtime-core", - "workflow-pack" - ], - "selectedModules": [ - "runtime-core", - "workflow-pack" - ], - "modules": [ - "runtime-core", - "workflow-pack" - ], - "managedFiles": [ - ".claude/skills/Amazon-ad-console/SKILL.md", - ".agents/skills/Amazon-ad-console/SKILL.md", - ".agents/skills/Amazon-ad-console/agents/openai.yaml", - ".claude/identity.json", - ".codex/config.toml", - ".codex/AGENTS.md", - ".codex/agents/explorer.toml", - ".codex/agents/reviewer.toml", - ".codex/agents/docs-researcher.toml", - ".claude/homunculus/instincts/inherited/Amazon-ad-console-instincts.yaml", - ".claude/commands/engine-bugfix-with-test.md", - ".claude/commands/api-route-bugfix-with-test.md" - ], - "packageFiles": { - "runtime-core": [ - ".claude/skills/Amazon-ad-console/SKILL.md", - ".agents/skills/Amazon-ad-console/SKILL.md", - ".agents/skills/Amazon-ad-console/agents/openai.yaml", - ".claude/identity.json", - ".codex/config.toml", - ".codex/AGENTS.md", - ".codex/agents/explorer.toml", - ".codex/agents/reviewer.toml", - ".codex/agents/docs-researcher.toml", - ".claude/homunculus/instincts/inherited/Amazon-ad-console-instincts.yaml" - ], - "workflow-pack": [ - ".claude/commands/engine-bugfix-with-test.md", - ".claude/commands/api-route-bugfix-with-test.md" - ] - }, - "moduleFiles": { - "runtime-core": [ - ".claude/skills/Amazon-ad-console/SKILL.md", - ".agents/skills/Amazon-ad-console/SKILL.md", - ".agents/skills/Amazon-ad-console/agents/openai.yaml", - ".claude/identity.json", - ".codex/config.toml", - ".codex/AGENTS.md", - ".codex/agents/explorer.toml", - ".codex/agents/reviewer.toml", - ".codex/agents/docs-researcher.toml", - ".claude/homunculus/instincts/inherited/Amazon-ad-console-instincts.yaml" - ], - "workflow-pack": [ - ".claude/commands/engine-bugfix-with-test.md", - ".claude/commands/api-route-bugfix-with-test.md" - ] - }, - "files": [ - { - "moduleId": "runtime-core", - "path": ".claude/skills/Amazon-ad-console/SKILL.md", - "description": "Repository-specific Claude Code skill generated from git history." - }, - { - "moduleId": "runtime-core", - "path": ".agents/skills/Amazon-ad-console/SKILL.md", - "description": "Codex-facing copy of the generated repository skill." - }, - { - "moduleId": "runtime-core", - "path": ".agents/skills/Amazon-ad-console/agents/openai.yaml", - "description": "Codex skill metadata so the repo skill appears cleanly in the skill interface." - }, - { - "moduleId": "runtime-core", - "path": ".claude/identity.json", - "description": "Suggested identity.json baseline derived from repository conventions." - }, - { - "moduleId": "runtime-core", - "path": ".codex/config.toml", - "description": "Repo-local Codex MCP and multi-agent baseline aligned with ECC defaults." - }, - { - "moduleId": "runtime-core", - "path": ".codex/AGENTS.md", - "description": "Codex usage guide that points at the generated repo skill and workflow bundle." - }, - { - "moduleId": "runtime-core", - "path": ".codex/agents/explorer.toml", - "description": "Read-only explorer role config for Codex multi-agent work." - }, - { - "moduleId": "runtime-core", - "path": ".codex/agents/reviewer.toml", - "description": "Read-only reviewer role config focused on correctness and security." - }, - { - "moduleId": "runtime-core", - "path": ".codex/agents/docs-researcher.toml", - "description": "Read-only docs researcher role config for API verification." - }, - { - "moduleId": "runtime-core", - "path": ".claude/homunculus/instincts/inherited/Amazon-ad-console-instincts.yaml", - "description": "Continuous-learning instincts derived from repository patterns." - }, - { - "moduleId": "workflow-pack", - "path": ".claude/commands/engine-bugfix-with-test.md", - "description": "Workflow command scaffold for engine-bugfix-with-test." - }, - { - "moduleId": "workflow-pack", - "path": ".claude/commands/api-route-bugfix-with-test.md", - "description": "Workflow command scaffold for api-route-bugfix-with-test." - } - ], - "workflows": [ - { - "command": "engine-bugfix-with-test", - "path": ".claude/commands/engine-bugfix-with-test.md" - }, - { - "command": "api-route-bugfix-with-test", - "path": ".claude/commands/api-route-bugfix-with-test.md" - } - ], - "adapters": { - "claudeCode": { - "skillPath": ".claude/skills/Amazon-ad-console/SKILL.md", - "identityPath": ".claude/identity.json", - "commandPaths": [ - ".claude/commands/engine-bugfix-with-test.md", - ".claude/commands/api-route-bugfix-with-test.md" - ] - }, - "codex": { - "configPath": ".codex/config.toml", - "agentsGuidePath": ".codex/AGENTS.md", - "skillPath": ".agents/skills/Amazon-ad-console/SKILL.md" - } - } -} \ No newline at end of file diff --git a/.claude/homunculus/instincts/inherited/Amazon-ad-console-instincts.yaml b/.claude/homunculus/instincts/inherited/Amazon-ad-console-instincts.yaml deleted file mode 100644 index 0cace79..0000000 --- a/.claude/homunculus/instincts/inherited/Amazon-ad-console-instincts.yaml +++ /dev/null @@ -1,508 +0,0 @@ -# Instincts generated from https://github.com/projectamazonph/Amazon-ad-console -# Generated: 2026-08-03T07:49:06.139Z -# Version: 2.0 -# NOTE: This file supplements (does not replace) any existing curated instincts. -# High-confidence manually curated instincts should be preserved alongside these. - ---- -id: Amazon-ad-console-commit-conventional -trigger: "when writing a commit message" -confidence: 0.85 -domain: git -source: repo-analysis -source_repo: https://github.com/projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Commit Conventional - -## Action - -Use conventional commit format with prefixes: fix - -## Evidence - -- 13 commits analyzed -- Detected conventional commit pattern -- Examples: fix: persist all feature-slice state, not just core state, fix: duplicateCampaign no longer collapses multi-ad-group targets - ---- -id: Amazon-ad-console-commit-length -trigger: "when writing a commit message" -confidence: 0.6 -domain: git -source: repo-analysis -source_repo: https://github.com/projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Commit Length - -## Action - -Write moderate-length commit messages (~63 characters) - -## Evidence - -- Average commit message length: 63 chars -- Based on 13 commits - ---- -id: Amazon-ad-console-naming-files -trigger: "when creating a new file" -confidence: 0.8 -domain: code-style -source: repo-analysis -source_repo: https://github.com/projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Naming Files - -## Action - -Use camelCase naming convention - -## Evidence - -- Analyzed file naming patterns in repository -- Dominant pattern: camelCase - ---- -id: Amazon-ad-console-export-style -trigger: "when exporting from a module" -confidence: 0.7 -domain: code-style -source: repo-analysis -source_repo: https://github.com/projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Export Style - -## Action - -Prefer named exports - -## Evidence - -- Export pattern analysis -- Dominant style: named - ---- -id: Amazon-ad-console-arch-type-based -trigger: "when adding new code" -confidence: 0.8 -domain: architecture -source: repo-analysis -source_repo: https://github.com/projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Arch Type Based - -## Action - -Place code in the appropriate type folder (components/, services/, utils/, etc.) - -## Evidence - -- Type-based module organization detected -- Folders: app, engine, lib - ---- -id: Amazon-ad-console-test-framework -trigger: "when writing tests" -confidence: 0.9 -domain: testing -source: repo-analysis -source_repo: https://github.com/projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Test Framework - -## Action - -Use vitest as the test framework - -## Evidence - -- Test framework detected: vitest -- File pattern: *.test.ts - ---- -id: Amazon-ad-console-test-naming -trigger: "when creating a test file" -confidence: 0.85 -domain: testing -source: repo-analysis -source_repo: https://github.com/projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Test Naming - -## Action - -Name test files using the pattern: *.test.ts - -## Evidence - -- File pattern: *.test.ts -- Consistent across test files - ---- -id: Amazon-ad-console-test-mocking -trigger: "when mocking dependencies in tests" -confidence: 0.75 -domain: testing -source: repo-analysis -source_repo: https://github.com/projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Test Mocking - -## Action - -Use vi.mock for mocking - -## Evidence - -- Mocking pattern detected: vi.mock -- Consistent across test files - ---- -id: Amazon-ad-console-test-types -trigger: "when planning tests for a feature" -confidence: 0.7 -domain: testing -source: repo-analysis -source_repo: https://github.com/projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Test Types - -## Action - -Write unit, integration tests to match project standards - -## Evidence - -- Test types detected: unit, integration -- Coverage config: no - ---- -id: Amazon-ad-console-workflow-engine-bugfix-with-test -trigger: "when doing engine bugfix with test" -confidence: 0.7 -domain: workflow -source: repo-analysis -source_repo: https://github.com/projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Workflow Engine Bugfix With Test - -## Action - -Follow the engine-bugfix-with-test workflow: -1. Identify and fix the bug in the relevant engine or feature file (e.g., core/engine/*.ts, features/*/engine.ts, features/*/store.ts). -2. Add or update a test in the corresponding __tests__ directory to cover the fixed behavior. -3. Commit both the implementation and the test together. - -## Evidence - -- Workflow detected from commit patterns -- Frequency: ~4x per month -- Files: src/engine/ad-console/core/engine/*.ts, src/engine/ad-console/features/*/engine.ts, src/engine/ad-console/features/*/store.ts - ---- -id: Amazon-ad-console-workflow-api-route-bugfix-with-test -trigger: "when doing api route bugfix with test" -confidence: 0.6 -domain: workflow -source: repo-analysis -source_repo: https://github.com/projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Workflow Api Route Bugfix With Test - -## Action - -Follow the api-route-bugfix-with-test workflow: -1. Fix the bug in the relevant API route file (e.g., /api/*/route.ts). -2. Add or update a test in the corresponding __tests__ directory for that route. -3. Commit both the route and its test together. - -## Evidence - -- Workflow detected from commit patterns -- Frequency: ~2x per month -- Files: src/app/api/*/route.ts, src/app/api/*/__tests__/route.test.ts - ---- -id: amazon-ad-console-instinct-file-naming -trigger: "When creating a new file in the codebase" -confidence: 0.85 -domain: code-style -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct File Naming - -## Action - -Name the file using camelCase - -## Evidence - -- Pattern in namingConventions.files -- Observed in src/app, src/engine, src/lib - ---- -id: amazon-ad-console-instinct-function-naming -trigger: "When defining a new function" -confidence: 0.85 -domain: code-style -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct Function Naming - -## Action - -Use camelCase for the function name - -## Evidence - -- Pattern in namingConventions.functions - ---- -id: amazon-ad-console-instinct-class-naming -trigger: "When creating a new class" -confidence: 0.85 -domain: code-style -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct Class Naming - -## Action - -Use PascalCase for the class name - -## Evidence - -- Pattern in namingConventions.classes - ---- -id: amazon-ad-console-instinct-constant-naming -trigger: "When declaring a constant" -confidence: 0.8 -domain: code-style -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct Constant Naming - -## Action - -Use SCREAMING_SNAKE_CASE for the constant name - -## Evidence - -- Pattern in namingConventions.constants - ---- -id: amazon-ad-console-instinct-import-style -trigger: "When importing modules" -confidence: 0.7 -domain: code-style -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct Import Style - -## Action - -Use a mixed import style as appropriate for the context - -## Evidence - -- Pattern in importStyle: mixed -- Seen in various src files - ---- -id: amazon-ad-console-instinct-export-style -trigger: "When exporting modules or functions" -confidence: 0.8 -domain: code-style -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct Export Style - -## Action - -Prefer named exports - -## Evidence - -- Pattern in exportStyle: named - ---- -id: amazon-ad-console-instinct-test-file-pattern -trigger: "When adding a new test file" -confidence: 0.9 -domain: testing -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct Test File Pattern - -## Action - -Name the test file with the pattern *.test.ts - -## Evidence - -- Pattern in testing.filePattern -- Files in __tests__ directories - ---- -id: amazon-ad-console-instinct-test-framework -trigger: "When writing tests" -confidence: 0.9 -domain: testing -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct Test Framework - -## Action - -Use the vitest framework - -## Evidence - -- Pattern in testing.framework -- Seen in test imports - ---- -id: amazon-ad-console-instinct-mocking -trigger: "When mocking dependencies in tests" -confidence: 0.85 -domain: testing -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct Mocking - -## Action - -Use vi.mock for mocking - -## Evidence - -- Pattern in testing.mockingStyle - ---- -id: amazon-ad-console-instinct-test-type -trigger: "When deciding on test granularity" -confidence: 0.8 -domain: testing -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct Test Type - -## Action - -Write both unit and integration tests as appropriate - -## Evidence - -- Pattern in testing.testTypes - ---- -id: amazon-ad-console-instinct-git-commit-format -trigger: "When making a commit" -confidence: 0.9 -domain: git -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct Git Commit Format - -## Action - -Use the conventional commit format with a prefix (e.g., fix: ...) - -## Evidence - -- Pattern in commits.type: conventional -- Examples: fix: ... - ---- -id: amazon-ad-console-instinct-git-commit-length -trigger: "When writing a commit message" -confidence: 0.7 -domain: git -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct Git Commit Length - -## Action - -Keep the commit message concise, around 60 characters - -## Evidence - -- Pattern in commits.averageLength: 63 - ---- -id: amazon-ad-console-instinct-engine-bugfix-workflow -trigger: "When a bug is found in core engine or feature logic" -confidence: 0.95 -domain: workflow -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct Engine Bugfix Workflow - -## Action - -Fix the bug and add or update a test in the corresponding __tests__ directory; commit both together - -## Evidence - -- Workflow: engine-bugfix-with-test -- Files: src/engine/ad-console/core/engine/*.ts, __tests__/*.test.ts - ---- -id: amazon-ad-console-instinct-api-route-bugfix-workflow -trigger: "When a bug is found in an API route handler" -confidence: 0.9 -domain: workflow -source: repo-analysis -source_repo: projectamazonph/Amazon-ad-console ---- - -# Amazon Ad Console Instinct Api Route Bugfix Workflow - -## Action - -Fix the bug and add or update a test in the corresponding __tests__ directory; commit both together - -## Evidence - -- Workflow: api-route-bugfix-with-test -- Files: src/app/api/*/route.ts, __tests__/route.test.ts - diff --git a/.claude/identity.json b/.claude/identity.json deleted file mode 100644 index 9ed23c3..0000000 --- a/.claude/identity.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": "2.0", - "technicalLevel": "technical", - "preferredStyle": { - "verbosity": "minimal", - "codeComments": true, - "explanations": true - }, - "domains": [ - "typescript" - ], - "suggestedBy": "ecc-tools-repo-analysis", - "createdAt": "2026-08-03T07:49:06.139Z" -} \ No newline at end of file diff --git a/.claude/skills/Amazon-ad-console/SKILL.md b/.claude/skills/Amazon-ad-console/SKILL.md deleted file mode 100644 index 3f424e6..0000000 --- a/.claude/skills/Amazon-ad-console/SKILL.md +++ /dev/null @@ -1,136 +0,0 @@ -```markdown -# Amazon-ad-console Development Patterns - -> Auto-generated skill from repository analysis - -## Overview - -This skill teaches you the core development patterns, coding conventions, and workflows used in the `Amazon-ad-console` TypeScript codebase. You'll learn how to follow the repository's conventions for file naming, imports/exports, commit messages, and testing. You'll also get step-by-step guidance for common bugfix workflows, including how to ensure all fixes are properly tested and committed. - -## Coding Conventions - -### File Naming - -- **Files:** Use `camelCase` for file names. - - Example: `adEngine.ts`, `adConsoleStore.ts` - -### Import Style - -- **Mixed imports:** Both default and named imports are used as appropriate. - - Example: - ```typescript - import React from 'react'; - import { fetchAds, updateAd } from './adEngine'; - ``` - -### Export Style - -- **Named exports** are preferred. - - Example: - ```typescript - // adEngine.ts - export function fetchAds() { /* ... */ } - export function updateAd() { /* ... */ } - ``` - -### Commit Messages - -- **Conventional commits** are used. -- Prefixes like `fix` are common. -- Messages are concise (~63 characters on average). - - Example: `fix: correct ad targeting logic in engine` - -## Workflows - -### Engine Bugfix with Test - -**Trigger:** When you discover a bug in the core logic or feature engine and want to fix it and ensure it doesn't regress. -**Command:** `/engine-bugfix` - -1. **Identify and fix the bug** in the relevant engine or feature file: - - `src/engine/ad-console/core/engine/*.ts` - - `src/engine/ad-console/features/*/engine.ts` - - `src/engine/ad-console/features/*/store.ts` - - `src/engine/ad-console/core/slices/*.ts` -2. **Add or update a test** in the corresponding `__tests__` directory to cover the fixed behavior: - - `src/engine/ad-console/core/__tests__/*.test.ts` - - `src/engine/ad-console/features/*/__tests__/*.test.ts` -3. **Commit both the implementation and the test together.** - - Example commit message: `fix: handle edge case in ad budget calculation` - -**Example:** -```typescript -// src/engine/ad-console/core/engine/adBudget.ts -export function calculateBudget(ad) { - // fixed logic here -} - -// src/engine/ad-console/core/__tests__/adBudget.test.ts -import { calculateBudget } from '../engine/adBudget'; -import { describe, it, expect } from 'vitest'; - -describe('calculateBudget', () => { - it('handles zero budget', () => { - expect(calculateBudget({ budget: 0 })).toBe(0); - }); -}); -``` - ---- - -### API Route Bugfix with Test - -**Trigger:** When you need to fix a bug in an API endpoint's logic and ensure correct behavior with a test. -**Command:** `/api-bugfix` - -1. **Fix the bug** in the relevant API route file: - - `src/app/api/*/route.ts` -2. **Add or update a test** in the corresponding `__tests__` directory for that route: - - `src/app/api/*/__tests__/route.test.ts` -3. **Commit both the route and its test together.** - - Example commit message: `fix: correct response for ad stats API` - -**Example:** -```typescript -// src/app/api/stats/route.ts -export function getAdStats(req, res) { - // fixed API logic here -} - -// src/app/api/stats/__tests__/route.test.ts -import { getAdStats } from '../route'; -import { describe, it, expect } from 'vitest'; - -describe('getAdStats', () => { - it('returns correct stats for valid ad', () => { - // test logic here - }); -}); -``` - -## Testing Patterns - -- **Framework:** [vitest](https://vitest.dev/) -- **Test files:** Use the pattern `*.test.ts` and are located in `__tests__` directories adjacent to the code. -- **Test structure:** Use `describe`, `it`, and `expect` for organizing and writing tests. - -**Example:** -```typescript -// src/engine/ad-console/core/__tests__/adEngine.test.ts -import { someFunction } from '../engine/adEngine'; -import { describe, it, expect } from 'vitest'; - -describe('someFunction', () => { - it('returns expected result', () => { - expect(someFunction()).toBe('expected'); - }); -}); -``` - -## Commands - -| Command | Purpose | -|-----------------|-------------------------------------------------------| -| /engine-bugfix | Fix a bug in engine/feature logic and add a test | -| /api-bugfix | Fix a bug in an API route and add a test | -``` diff --git a/.codex/AGENTS.md b/.codex/AGENTS.md deleted file mode 100644 index 6856194..0000000 --- a/.codex/AGENTS.md +++ /dev/null @@ -1,27 +0,0 @@ -# ECC for Codex CLI - -This supplements the root `AGENTS.md` with a repo-local ECC baseline. - -## Repo Skill - -- Repo-generated Codex skill: `.agents/skills/Amazon-ad-console/SKILL.md` -- Claude-facing companion skill: `.claude/skills/Amazon-ad-console/SKILL.md` -- Keep user-specific credentials and private MCPs in `~/.codex/config.toml`, not in this repo. - -## MCP Baseline - -Treat `.codex/config.toml` as the default ECC-safe baseline for work in this repository. -The generated baseline enables GitHub, Context7, Exa, Memory, Playwright, and Sequential Thinking. - -## Multi-Agent Support - -- Explorer: read-only evidence gathering -- Reviewer: correctness, security, and regression review -- Docs researcher: API and release-note verification - -## Workflow Files - -- `.claude/commands/engine-bugfix-with-test.md` -- `.claude/commands/api-route-bugfix-with-test.md` - -Use these workflow files as reusable task scaffolds when the detected repository workflows recur. \ No newline at end of file diff --git a/.codex/agents/docs-researcher.toml b/.codex/agents/docs-researcher.toml deleted file mode 100644 index 0daae57..0000000 --- a/.codex/agents/docs-researcher.toml +++ /dev/null @@ -1,9 +0,0 @@ -model = "gpt-5.4" -model_reasoning_effort = "medium" -sandbox_mode = "read-only" - -developer_instructions = """ -Verify APIs, framework behavior, and release-note claims against primary documentation before changes land. -Cite the exact docs or file paths that support each claim. -Do not invent undocumented behavior. -""" \ No newline at end of file diff --git a/.codex/agents/explorer.toml b/.codex/agents/explorer.toml deleted file mode 100644 index 732df7a..0000000 --- a/.codex/agents/explorer.toml +++ /dev/null @@ -1,9 +0,0 @@ -model = "gpt-5.4" -model_reasoning_effort = "medium" -sandbox_mode = "read-only" - -developer_instructions = """ -Stay in exploration mode. -Trace the real execution path, cite files and symbols, and avoid proposing fixes unless the parent agent asks for them. -Prefer targeted search and file reads over broad scans. -""" \ No newline at end of file diff --git a/.codex/agents/reviewer.toml b/.codex/agents/reviewer.toml deleted file mode 100644 index b13ed9c..0000000 --- a/.codex/agents/reviewer.toml +++ /dev/null @@ -1,9 +0,0 @@ -model = "gpt-5.4" -model_reasoning_effort = "high" -sandbox_mode = "read-only" - -developer_instructions = """ -Review like an owner. -Prioritize correctness, security, behavioral regressions, and missing tests. -Lead with concrete findings and avoid style-only feedback unless it hides a real bug. -""" \ No newline at end of file diff --git a/.codex/config.toml b/.codex/config.toml deleted file mode 100644 index bc1ee67..0000000 --- a/.codex/config.toml +++ /dev/null @@ -1,48 +0,0 @@ -#:schema https://developers.openai.com/codex/config-schema.json - -# ECC Tools generated Codex baseline -approval_policy = "on-request" -sandbox_mode = "workspace-write" -web_search = "live" - -[mcp_servers.github] -command = "npx" -args = ["-y", "@modelcontextprotocol/server-github"] - -[mcp_servers.context7] -command = "npx" -args = ["-y", "@upstash/context7-mcp@latest"] - -[mcp_servers.exa] -url = "https://mcp.exa.ai/mcp" - -[mcp_servers.memory] -command = "npx" -args = ["-y", "@modelcontextprotocol/server-memory"] - -[mcp_servers.playwright] -command = "npx" -args = ["-y", "@playwright/mcp@latest", "--extension"] - -[mcp_servers.sequential-thinking] -command = "npx" -args = ["-y", "@modelcontextprotocol/server-sequential-thinking"] - -[features] -multi_agent = true - -[agents] -max_threads = 6 -max_depth = 1 - -[agents.explorer] -description = "Read-only codebase explorer for gathering evidence before changes are proposed." -config_file = "agents/explorer.toml" - -[agents.reviewer] -description = "PR reviewer focused on correctness, security, and missing tests." -config_file = "agents/reviewer.toml" - -[agents.docs_researcher] -description = "Documentation specialist that verifies APIs, framework behavior, and release notes." -config_file = "agents/docs-researcher.toml" \ No newline at end of file diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 20dddcd..0000000 --- a/.dockerignore +++ /dev/null @@ -1,14 +0,0 @@ -node_modules -.next -.git -.gitignore -*.md -.DS_Store -.env*.local -.env -out -build -dist -.playwright -test-results -playwright-report diff --git a/.env.example b/.env.example deleted file mode 100644 index de883f7..0000000 --- a/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# Amazon Ad Console Training Simulator -# -# The simulator itself is fully client-side and needs no Amazon API access, -# but the multi-user features (registration, login, cloud sync) need a -# Postgres database and a NextAuth session secret. Both are required at -# runtime in production. Local dev can run with empty stubs but registration -# and login will fail until they are set. - -# Postgres connection string (e.g. from Vercel Storage → Postgres, or Neon directly) -DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require" - -# NextAuth/Auth.js session secret — generate with: openssl rand -base64 32 -AUTH_SECRET="" - -# Optional: app origin for absolute URLs (defaults to http://localhost:3000 in dev) -# NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a901d27..1e49c13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ name: CI on: push: branches: [main] + pull_request: branches: [main] diff --git a/.gitignore b/.gitignore index 3cae870..f88b7fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,24 @@ -# >>> cortexkit:magic-context -.cortexkit/ -magic-context/ -# <<< cortexkit:magic-context +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -node_modules/ -.pnp -.pnp.js +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage # next.js -.next/ -out/ +/.next/ +/out/ # production -build/ -dist/ +/build # misc .DS_Store @@ -24,26 +28,31 @@ dist/ npm-debug.log* yarn-debug.log* yarn-error.log* +.pnpm-debug.log* -# env files -.env*.local -.env +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel # typescript *.tsbuildinfo next-env.d.ts -# IDE -.idea/ -.vscode/ +# ijfw project state (runtime, not source) +/.ijfw/ +/ijfw/ + +# IDE / editor +/.vscode/ +/.idea/ *.swp *.swo -.vercel -# playwright -test-results/ -playwright-report/ +# OS +Thumbs.db +desktop.ini -/src/generated/prisma -*.db -.loop/ +# local dev logs +*.log diff --git a/.impeccable/critique/2026-07-24T02-45-50Z__landing-page.md b/.impeccable/critique/2026-07-24T02-45-50Z__landing-page.md deleted file mode 100644 index 3752543..0000000 --- a/.impeccable/critique/2026-07-24T02-45-50Z__landing-page.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -target: critique -total_score: 21 -p0_count: 0 -p1_count: 3 -timestamp: 2026-07-24T02-45-50Z -slug: landing-page ---- -## Design Health Score - -| # | Heuristic | Score | Key Issue | -|---|-----------|-------|-----------| -| 1 | Visibility of System Status | 2 | Static page; hero badge and stats are decorative | -| 2 | Match System / Real World | 2 | Dual audience contradiction: hero (B2C), features (B2B) | -| 3 | User Control and Freedom | 3 | Clear single path; limited but acceptable for landing page | -| 4 | Consistency and Standards | 2 | 4 hardcoded hex values outside token block; radius inconsistency | -| 5 | Error Prevention | 3 | No forms; Watch Demo links to non-existent video | -| 6 | Recognition Rather Than Recall | 2 | 5 nav items visible but ghost CTA mixed in | -| 7 | Flexibility and Efficiency | 1 | No keyboard shortcuts, no skip-to-content | -| 8 | Aesthetic and Minimalist Design | 2 | Particles, rotating glow, animated dot = decorative noise | -| 9 | Error Recovery | 3 | No error states on this surface | -| 10 | Help and Documentation | 1 | Documentation nav link is dead (#) | -| **Total** | | **21/40** | **Acceptable** | - ---- - -## Anti-Patterns Verdict - -### LLM Assessment: Guilty — Dark SaaS template with Amazon paint - -Hits multiple AI-generation signatures: eyebrow badge + pulsing dot, floating particle field, radial glow behind CTA, 3-step numbered timeline with connecting line, hero-metric template in stats band. Technical execution is clean but aesthetic is template-adjacent. - -### Deterministic Scan - -- CLI detector: Empty (false negative — doesn't scan globals.css for embedded styles) -- Manual: No gradient text, no glassmorphism defaults, no Inter, good contrast throughout -- Token violations: #5dd3a8, #34D399, #FF5F57, #FFBD2E, #28CA41, #F3A847 all hardcoded outside --landing-* block - ---- - -## Overall Impression - -Technically solid — CSS architecture, tokens, motion, and accessibility wiring are correct. But reads as a Figma template labeled "Dark SaaS Hero + Bento Grid + 3-Step Timeline" with Amazon orange painted on. Design has no POV. Core issue: hero targets individuals (B2C), features target agency admins (B2B). The page cannot convert either audience effectively by speaking to both simultaneously. - ---- - -## What's Working - -1. Amazon brand coherence — #FF9900 on dark slate is immediately Amazon-adjacent -2. Motion discipline — staggered FadeIn with useInView, expo-out curve, prefers-reduced-motion wired -3. Color contrast passes — all body text exceeds WCAG AA (5.2:1 minimum) - ---- - -## Priority Issues - -**P1 — Dual-Audience Contradiction**: Hero speaks to individuals ("Start Training Free"), features speak to agencies ("Onboard VAs 60% faster", "Team Training"). Visitor doesn't know if they're the buyer. Pick one primary persona. Fix: Rewrite either hero to be agency-forward or features to be individual-forward. - -**P1 — "Watch Demo" Links to Nothing**: "Watch Demo" CTA anchors to #preview which shows a static image, not a video. Broken promise creates disappointment. Fix: Remove "Watch Demo" and replace with "See the Simulator" or remove the anchor entirely. - -**P1 — Hardcoded Hex Values**: 4+ hardcoded hex colors (#5dd3a8, #34D399, #FF5F57, #FFBD2E, #28CA41, #F3A847) exist outside the --landing-* token block. Token violations will be missed during brand updates. Fix: Add --landing-success and chrome-dot tokens to .landing-page block. - -**P2 — Typography Whiplash**: Feature card body (0.95rem) is smaller than hero subtitle (1.25rem) — hierarchy inversion within same scroll. Stat values (3.5rem) dwarf feature headings (1.1rem). Fix: Bring feature card body to 1rem minimum; cap stats at 2.5rem; establish clear type tier. - -**P2 — Animated Decorative Noise**: 12 floating particles, rotating radial glow, pulsing green dot — all simultaneously active in the hero. Each animation competes before the headline is read. Fix: Pick one ambient effect maximum; kill rotating glow; reduce or remove particles. - -**P2 — Fake Social Proof**: "500+ VAs Trained" and "Maria Santos, E-commerce Director" have no verification. Unverified claims damage trust with skeptical e-commerce professionals. Fix: Add real data with attribution, or replace with qualitative claims ("Used by teams across Southeast Asia"). - -**P3 — Dead Navigation Link**: "Documentation" in nav is href="#". Dead links signal incomplete work. Fix: Link to real docs or remove the nav item. - ---- - -## Persona Red Flags - -**Jordan (Confused First-Timer)**: Icon-only "Get Started" button with no text label. "Watch Demo" suggests video but leads to screenshot. No visible explanation of what "simulator" means. - -**Casey (Distracted Mobile User)**: Feature grid collapses from 3-col to 1-col with no intermediate breakpoint. May feel like wasted space on tablet. - -**Alex (Impatient Power User)**: No keyboard navigation. No skip-to-content link. "Documentation" nav link is dead — power users always check docs first. - ---- - -## Minor Observations - -- Radius inconsistency: Nav pills 100px, cards 16px, buttons 8-10px — no systematic token -- Section padding 7.5rem creates dramatic pacing but makes page feel long -- --font-display used for body text in landing — display fonts optimized for headlines -- Nav has no hamburger menu at mobile — items disappear entirely -- Badge copy conflict: "PPC Training Made Simple" vs "Master Amazon PPC" = different promises diff --git a/AGENTS.md b/AGENTS.md index 0b013cf..9175bbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,79 +1,36 @@ -# AGENTS.md — Amazon Ad Console - -## Stack - -- **Framework**: Next.js 16, React 19, TypeScript ~5.8 -- **State**: Zustand 5 -- **Database**: Prisma 7 + Postgres (via `@prisma/adapter-neon`) -- **Auth**: NextAuth 5 (beta) -- **Testing**: Vitest 4, Playwright 1.61 -- **Styling**: `@astryxdesign/core` components + CSS custom-property tokens (no Tailwind compiler wired up despite the name appearing in some older docs) - -## Build & Test - -```bash -npm install -npm run build # Next.js build -npm run test # Vitest unit/integration tests -npm run test:e2e # Playwright end-to-end -npm run lint # ESLint -npm run type-check # tsc --noEmit -``` - -## Project Structure - -``` -src/ -├── engine/ad-console/ # Core simulation engine (well-tested, 312+ tests) -│ ├── core/ # Domain types, simulation, slices -│ ├── features/ # Feature slices (bulk, drills, reports, etc.) -│ └── store.ts # Composed Zustand store -├── app/ # Next.js App Router pages -└── components/ # React UI components -``` - -## Review Norms - -- Engine layer (`src/engine/`) is stable — changes need tests -- Component layer has known SOLID violations — refactor incrementally, not all at once -- One fix per loop run — no drive-by refactors -- Always run `npm run test` before proposing changes -- Never edit `.env`, `prisma/`, or auth configs without human approval - -## Loop Integration - -- State tracked in `STATE.md` -- Budget enforced via `loop-budget.md` -- Constraints in `loop-constraints.md` -- Run history in `loop-run-log.md` - - -Astryx v0.1.8 · 153 components -CLI: run every command as `npx astryx ` (shown below as `astryx ...`). - -SETUP (once, in your app entry e.g. main.tsx) — without these, components render unstyled: - import "@astryxdesign/core/reset.css"; - import "@astryxdesign/core/astryx.css"; - -WORKFLOW — discover, don't guess. Before writing UI: -1. `astryx build ""` — START HERE: returns a kit (closest [page] + [block]s + [component]s). No args = full playbook. -2. `astryx template [--skeleton]` — scaffold the [page]/[block]s it named, or study their layout. Templates are reference code. -3. `astryx component ` — props + examples for every component you use. - -RULES: -- No
— components do all layout/spacing. Full page → AppShell; sidebar nav → SideNav. -- Frame first: pick the shell (AppShell / Layout+LayoutPanel) and budget regions in px BEFORE writing content (`astryx docs layout`). -- Dense data = rows (Table, List/Item) edge-to-edge — never Card-wrapped list items. Card = dashboard widgets, galleries, settings groups only. -- Status → StatusDot/Token; Badge only for counts and enumerated states, never decoration. -- Custom styling: component props first; else style/className with tokens — var(--color-*|--spacing-*|--radius-*). No raw hex/px. (No StyleX/Tailwind compiler here — don't use xstyle/utility classes.) -- Tokens for every value (`astryx docs tokens`). Brand/accent via `astryx theme` — never override --color-* in :root. -- SELF-CHECK before you finish: re-read the file and replace any raw
/ layout, imported .css/@apply, or hardcoded value (#hex, 16px) with the component or a token (var(--color-*|--spacing-*|…)). If unsure a component/prop exists, run `astryx component ` / `astryx search ""`; don't hand-roll CSS. - -MORE CLI: - search "" find any component / hook / doc / template / block - component --list 153 components by category - template --list page + block recipes - docs color, elevation, icons, illustrations, internationalization, layout, migration, motion, principles, shape, spacing, styling, theme, tokens, typography - swizzle eject component source for deep customization - upgrade --apply run after any @astryxdesign/core bump - +--- +ijfw_version: 1.3.2 +ijfw_schema: 1 +type: software +primary_type: software +secondary_types: [] +confidence: 0.871 +detected_at: 2026-07-25T04:46:14.773Z +signals: + - kind: manifest + weight: 0.9 + manifests: [package.json] + - kind: file_extension_ratio + weight: 0.7 + domain: software + ratio: 0.545 + count: 6 + - kind: file_extension_ratio + weight: 0.7 + domain: design + ratio: 0.455 + count: 5 +--- + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + + + +Project memory at .ijfw/memory/. Call `ijfw_memory_prelude` for full context. + + + +No project agents yet. Run `ijfw team` to set them up. + diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 8c76c32..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,86 +0,0 @@ -# Changelog - -All notable changes to this project are documented in this file. - -The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -This file starts at 3.6.0 — earlier releases were not retroactively documented. - -## [3.6.0] - 2026-08-03 - -### Fixed - -- **Sidebar navigation was never mounted.** The `Sidebar` component and its - `getLeftRail`/training-rail nav model (`nav/consoleNav.ts`) were fully - built and unit-tested but never rendered into `AdConsole.tsx` — Missions, - Reports, Bulk ops, Trainer, and Integrity were unreachable from the - desktop UI. `MobileNav` had the same gap from a different angle (its - section resolution only ever returned `campaigns`/`portfolio`). Both are - now wired through the shared `sidebarSectionForView`/`isSidebarItemActive`/ - `resolveSidebarClick` helpers. -- **Every Astryx `Card`'s `padding` prop silently rendered as `0px` - sitewide.** Root cause: Astryx ships component styles inside - `@layer astryx-base`/`@layer astryx-theme`, and per the CSS - cascade-layers spec an unlayered declaration always beats a layered one - regardless of specificity. This app's global reset - (`*, *::before, *::after { ...; padding: 0; }`) was unlayered, so it - unconditionally zeroed every Astryx padding prop — this is why content - (buttons, headings, form fields) so often sat flush against card edges. - Scoped the reset down to just `ul`/`ol` (the only elements relying on it). -- Campaign creation wizard's Review & Launch step showed a "Lookback: 30 - days" row on every campaign, including plain Sponsored Products, because - `audienceLookback` defaults to `'30'` regardless of type. Gated it on the - campaign's targeting mode actually being an audience mode. Also added - missing review rows for ASIN/category/audience targets and SB/SD creative - fields (headline, brand, destination) that were entered earlier in the - wizard but never shown before launch. -- `.split`'s `2fr 1fr` grid (dashboard "Operator alerts"/"Training - coverage" cards) didn't shrink to fit once the sidebar took up real - width, clipping content past the viewport edge. Fixed with - `minmax(0, ...)` tracks. -- `adjustTargetBid` (the "-10%"/"+10%" bid buttons) threw an uncaught - error when decrementing an already-cheap bid below the platform - minimum, instead of flooring it — a regression from the bid - fail-fast change below. -- `setTargetBid`/`setAdGroupDefaultBid` no longer silently substitute a - bid below the real $0.02 minimum; they fail fast via a new - `assertValidBid`/`MIN_BID` (`src/lib/validation.ts`), matching this - codebase's existing fail-fast convention. Creation/normalization paths - (`addTarget`, `normalizeCampaign`) still clamp, since those fill in - defaults for incomplete data rather than acting on explicit user intent. -- Campaign Manager's empty state no longer says "No campaigns yet" when a - search/filter simply matched nothing — it now shows a distinct "no - matches" state with a "Clear filters" action. -- Fixed a campaign-ID collision risk in `launchCampaign` (two campaigns of - the same type launched within the same millisecond could get the same - ID) by switching to the shared `generateId` helper, and deduplicated the - same ad-hoc ID-generation pattern across the `profiles`/`reports`/ - `trainer`/`integrity` feature engines. -- Fixed literal mojibake (`ΓÇö`, `ΓåÆ`) in the landing page copy and a - missing `.object-cover` rule that left two landing-page images without - `object-fit` applied. -- Replaced dead Tailwind sizing classes (`w-6 h-6`, `w-4 h-4`) on landing - page icons with explicit SVG dimensions — this repo has no Tailwind - compiler wired up, so the classes were doing nothing. - -### Added - -- Contract tests pinning the sidebar fix (renders, drives navigation to - the previously-unreachable views) and the wizard review-step fix. -- Regression tests for `isVideoFormat`, the shared `generateId` migration - (same-millisecond uniqueness across all four feature engines), and the - simulation's search-term dedup across repeated `simulateDays` calls. - -### Changed - -- Deduplicated hand-rolled metrics/formatter logic in `PortfolioOverview`, - `Dashboard`, and `CampaignManager` onto the shared `totalMetrics`/ - `formatMoney`/`formatWhole`/`formatPercent` engine functions. -- Un-Card-wrapped dense tables in `PortfolioOverview`, `Dashboard`, - `OverviewTab`, `BulkOpsPage`, and `ReportsPage` per this repo's own - convention (dense data renders edge-to-edge, `Card` is for dashboard - widgets/settings groups only). -- Fixed an O(n·m) duplicate-detection loop in the search-term simulator - (now O(1) via a `Set`) and memoized a few expensive per-render - aggregations (`Dashboard`, `ManagerSearchTermsTab`). -- Removed 13 dead `useState` hooks and unused imports from - `CreateCampaignWizard`. diff --git a/CLAUDE.md b/CLAUDE.md index d8a08d7..d8f2a0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,133 +1,4 @@ -# CLAUDE.md +@AGENTS.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## What this is - -A pixel-faithful Next.js replica of the Amazon Ads Console (`advertising.amazon.com`) used to train VAs and eCommerce teams on PPC campaign management — offline, risk-free, with built-in coaching (drills, missions, trainer dashboard, integrity checks). The simulation engine is deliberately isolated from the UI so it can be ported into other apps (see "Porting" below). - -## Commands - -```bash -npm install # postinstall runs `prisma generate` automatically -npm run dev # dev server on :3000 -npm run build # production build (Next.js standalone output) -npm run lint # next lint -npm run type-check # tsc --noEmit — run this before considering a change done -npm test # vitest run (unit/integration, single pass) -npm run test:watch # vitest watch mode -npm run test:e2e # Playwright e2e (auto-boots `npm run dev` on :3000) -npm run test:e2e:ui # Playwright with UI runner -npx prisma migrate dev # apply DB migrations (schema.prisma is Postgres) -npx prisma generate # regenerate client into src/generated/prisma -``` - -Run a single Vitest test file or test name: -```bash -npx vitest run src/engine/ad-console/core/__tests__/engine.test.ts -npx vitest run -t "adds a keyword target" -``` - -Run a single Playwright spec: -```bash -npx playwright test e2e/campaign-wizard.spec.ts -``` - -CI (`.github/workflows/ci.yml`) runs, in order: `type-check` → `test` → `build`. Match that locally before pushing. - -Coverage thresholds (vitest.config.ts): 80% statements/functions/lines, 70% branches, over `src/**/*.{ts,tsx}` minus components, tests, `.d.ts` files, `store.ts`, and a couple of named exclusions — see the `coverage.exclude` list for specifics. - -## Architecture - -Layered, in order of dependency (top depends on bottom, never the reverse): - -```text -Next.js App Router (src/app) — pages, layouts, API routes -React components (src/components/AdConsole) — presentation only -Zustand store (src/engine/ad-console/store.ts) — composed of 8 core slices + 7 feature slices -Feature engines (src/engine/ad-console/features//{types,engine,store}.ts) -Core engine (src/engine/ad-console/core/) — zero framework dependencies, pure functions -``` - -**`core/engine/`, `core/types.ts`, and `core/simulation.ts` have zero React/Next/Zustand dependencies.** They are pure TypeScript: given state in, return new state out, no mutation, no side effects. This is the most important invariant in the codebase — it's what makes those modules portable and unit-testable in isolation. Never import React, Next.js, or store code into them. Note that `core/slices/` (below) is the one exception within `core/` — it depends on Zustand's `StateCreator` type by design, since its job is to wrap the pure engine in store slices. - -- `core/types.ts` — every domain interface (Campaign, AdGroup, Target, Negative, BudgetRule, Portfolio, Metrics, etc.) -- `core/engine/` — one module per domain concern: `campaign.ts`, `target.ts`, `adgroup.ts`, `negative.ts`, `budget.ts`, `portfolio.ts`, `draft.ts`, `id.ts`, `metrics.ts`, `responsive.ts`, `search-term-generator.ts`. All re-exported through `core/engine/index.ts`. `campaign.ts` also exports `isVideoFormat(type, adFormat)`, the single source of truth for which `adFormat` string means "video" for a given campaign type (SB uses `'Video'`, SD uses `'Video creative'`) — used by both the engine and `OverviewTab`. -- `core/simulation.ts` — the 7-day performance simulator; metrics cascade target → ad group → campaign → dashboard. -- `core/slices/` — Zustand-dependent `StateCreator` slices (core, target, adgroup, negative, budget, portfolio, draft) that wrap the pure engine functions with state. -- `features//` — self-contained modules (`drills`, `profiles`, `trainer`, `bulk`, `reports`, `missions`, `integrity`), each with its own `types.ts`, `engine.ts`, `store.ts`. Adding a feature means adding a new directory here — existing files shouldn't need edits (open/closed). -- `store.ts` — combines every slice into one `AppStore` type via intersection and creates the single Zustand store (localStorage-persisted, with optional cloud sync). - -Entity hierarchy the engine models: -```text -Account → Portfolio → Campaign (SP/SB/SD) → AdGroup → Target (keyword/ASIN/category/auto/audience) - → ProductAd / Ad (creative) - → SearchTerm (report data linked to Target) - → Negative (campaign- or ad-group-level) - → BudgetRule -``` - -Import from the public barrel when consuming the engine from UI code: -```ts -import { calc, simulateDays, useAdConsoleStore } from '@/engine/ad-console'; -``` -`@/*` maps to `src/*` (tsconfig + vitest alias). - -### Data flow -- Client state: `User Action → Component → Store Slice → Engine Function → New State → Re-render`. -- Server-side: `Component → /api/* route → Prisma (Neon adapter) → Postgres`, gated by `auth()` session checks on every route, with all queries scoped by `userId`. -- Persistence is dual: Zustand `persist` middleware keeps state in localStorage for offline/no-login use; `/api/sync` optionally pushes/pulls the same shape to Postgres for logged-in users. Campaign fields that are structurally nested (adGroups, targets, negatives, etc.) are stored as JSON strings in Postgres, not relational tables — see `prisma/schema.prisma`. - -### Auth -NextAuth v5 (beta), Credentials provider, JWT sessions, bcrypt password hashing. Config in `src/lib/auth.ts`. Every protected `/api/*` route must check `const session = await auth(); if (!session?.user?.id) return 401`. The two public exceptions are `/api/auth/register` and `/api/auth/[...nextauth]` (login/session handling itself) — those must work without an existing session. - -### Database reality check -`prisma/schema.prisma` targets **Postgres** (via `@prisma/adapter-neon`, `src/lib/prisma.ts`), not SQLite — some older docs (README, AGENTS.md) still say SQLite; trust the schema and `.env.example` over those. `DATABASE_URL` and `AUTH_SECRET` are required at runtime for registration/login/sync to work; the simulator itself runs fully client-side without them. - -### UI conventions (Astryx design system) -Components come from `@astryxdesign/core` (153 components, theme via `@astryxdesign/theme-neutral`). This is actively used across the component tree (~40 files) — don't hand-roll layout `
`s or raw CSS when an Astryx component/prop/token covers it. Key rules (full detail lives in `AGENTS.md`'s Astryx block): -- No raw `
` for layout — components handle layout/spacing (`AppShell` for full pages, `SideNav` for sidebar nav). In practice the component layer predates full Astryx adoption and still uses hand-rolled `.app-layout`/`.app-sidebar`/`.app-main` divs throughout (see "known SOLID violations" below) — match the existing pattern in a file rather than mixing conventions mid-component. -- Dense data → `Table`/`List`/`Item` rows edge-to-edge, never Card-wrapped. `Card` is for dashboard widgets/galleries/settings groups only. -- Styling values must be tokens (`var(--color-*|--spacing-*|--radius-*)`) — no raw hex/px, no Tailwind utility classes (this repo has no Tailwind compiler wired up despite Tailwind appearing in some older docs). -- Discover components/props via the CLI: `npm run astryx -- component `, `npm run astryx -- search ""`, `npm run astryx -- build ""`. -- **Never add `padding` (or any box-model property Astryx components expose as a prop) to a bare-selector reset in `globals.css`** (e.g. `*, *::before, *::after { ... }`). Astryx ships its component styles inside `@layer astryx-base`/`@layer astryx-theme`; per the CSS cascade-layers spec, *any* unlayered declaration beats a layered one regardless of specificity. An unlayered `* { padding: 0 }` silently zeroed every Astryx `padding` prop sitewide until it was found and fixed (3.6.0) — the global reset only zeroes `margin`, plus `padding` on the couple of native elements (`ul`, `ol`) that actually need it. If a future reset-like rule needs to beat Astryx's own styling, put it in the unlayered `src/app/astryx-theme.css` bridge scoped to the specific class/selector, not a wildcard. -- Empty states use the shared `EmptyState` component (`src/components/AdConsole/details/EmptyState.tsx`) — icon + title + optional message, not a bare `Card` with a muted paragraph. - -### Validation -Engine functions fail fast: invalid input throws `ValidationError` (`src/lib/validation.ts`) rather than silently clamping or producing `NaN`. Follow this pattern for new engine functions — don't add silent fallbacks. `MIN_BID` and `assertValidBid` (also in `src/lib/validation.ts`) enforce the $0.02 platform bid floor for "set an explicit bid" actions (`setTargetBid`, `setAdGroupDefaultBid`); creation/normalization paths (`addTarget`, `normalizeCampaign`) still clamp instead of throwing, since those fill in defaults for incomplete data rather than acting on explicit user intent. Relative adjustments (`adjustTargetBid`, the "±10%" buttons) floor at `MIN_BID` rather than fail fast, since the caller doesn't fully control the resulting value. - -## Testing conventions - -- Engine/core tests live beside the code in `src/engine/ad-console/**/__tests__/*.test.ts` (TDD — write the failing test first, keep the engine framework-free and easy to test in isolation). -- Component/integration tests: `src/components/AdConsole/__tests__/` (Vitest + React Testing Library). -- Legacy top-level tests: `tests/engine.test.ts`, `tests/next-config.test.ts`. -- E2E specs: `e2e/*.spec.ts` (Playwright, one browser project — chromium — boots the real dev server). -- When changing engine behavior, add/adjust unit tests in the same PR; the engine layer is considered stable and changes without tests should be treated as suspect. - -## Working conventions specific to this repo - -These come from `AGENTS.md`, `LOOP.md`, `loop-constraints.md`, and `gate.yaml` — they apply to automated/agentic changes here and are good defaults for any change: - -- **Never edit without explicit human approval**: `.env`/`.env.*`, `prisma/schema.prisma` or `prisma/migrations/`, `next.config.ts`, `src/lib/auth.ts`, or files matching `*_key*`/`*_secret*` (the latter two patterns are enforced via `gate.yaml`'s denylist). -- Always run `npm run test` before proposing a change as done. -- One fix per change — no drive-by refactors bundled into unrelated work. -- The engine layer (`src/engine/`) is stable; treat changes there as needing test coverage. The component layer has known SOLID violations (`CreateCampaignWizard`, `CampaignManager`, `CampaignDetail`) — refactor incrementally, not all at once, and don't attempt a full rewrite unprompted. -- Don't auto-merge or push without being asked; this repo's own agent-loop tooling (`gate.yaml`, `loop-*.md`) treats `docs/**` and `*.md` as the only auto-mergeable paths and requires human review for everything else. - -## Repo layout notes - -- `legacy/` holds the pre-Next.js prototype (a single-file `amazon_ppc_simulator.html` with inline JS) and its old QA/docs — historical reference only, not part of the current build. -- `codegraphs/Amazon-ad-console.md` describes that old single-file prototype and is stale relative to the current Next.js/engine architecture described above; don't rely on it. -- `docs/` has deeper reference material: `ARCHITECTURE.md`, `API.md` (full engine function signatures), `SCHEMA.md`, `FEATURES.md`, `INTEGRATION.md` (porting guide), `AUTH.md`, `AUDIT-FOLLOWUPS.md`. -- `CHANGELOG.md` (repo root) tracks notable changes per release starting at 3.6.0; bump `version` in `package.json` (and the unused-but-should-stay-in-sync `coreState.version` in `core/slices/core.ts`) together with a new entry when cutting a release. -- `skills/`, `patterns/`, `gate.yaml`, `STATE.md`, `loop-*.md` support an autonomous triage/fix loop tool used against this repo — not part of the app runtime. -- `.claude/`, `.agents/`, `.codex/` (added via the `ecc-tools` bot PR #56) are an auto-generated agent-tooling bundle: a repo skill, Codex config/agent roles, workflow command scaffolds, and "continuous learning instincts" derived from git-history analysis — not part of the app runtime either. Treat `.claude/skills/Amazon-ad-console/SKILL.md` as unverified: it was generated from commit-history heuristics and contains at least one claim that doesn't match this repo (it says filenames use `camelCase` with invented examples like `adEngine.ts`; the real convention is PascalCase for components (`CampaignManager.tsx`) and lowercase-per-domain-concern for engine modules (`core/engine/campaign.ts`), per the Architecture section above). This file (`CLAUDE.md`) is the authoritative guide — prefer it over the generated skill wherever they disagree. - -## Porting the engine - -The entire `src/engine/ad-console/` tree is designed to be copied into other apps with zero changes, provided the target app also runs Zustand 5 (`store.ts` and `core/slices/` depend on it — only `core/engine/`, `core/types.ts`, and `core/simulation.ts` are fully dependency-free): -```ts -import { useAdConsoleStore } from '@/engine/ad-console/store'; -import { calc, simulateDays } from '@/engine/ad-console/core/engine'; -``` -See `docs/INTEGRATION.md` for the full guide. + + diff --git a/CampaignManager.jsx (1).txt b/CampaignManager.jsx (1).txt deleted file mode 100644 index 25ab079..0000000 --- a/CampaignManager.jsx (1).txt +++ /dev/null @@ -1,646 +0,0 @@ -import { useState, useEffect, useMemo } from "react"; -import { - Plus, - Search, - ChevronDown, - Check, - X, - MoreHorizontal, - Pause, - PlayCircle, - Archive, - Columns3, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import { Badge } from "@/components/ui/badge"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuCheckboxItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; - -// --------------------------------------------------------------------------- -// Design tokens (from ppc-simulator-ui-ux-spec.md §1) — kept inline here so -// this file is drop-in runnable; in the real app these belong in tailwind.config -// --------------------------------------------------------------------------- -const TYPE_BADGE = { - SP: "bg-[#E8F1FF] text-[#0B5FBA] border-[#0B5FBA]/20", - SB: "bg-[#F3ECFB] text-[#7B3FBF] border-[#7B3FBF]/20", - SD: "bg-[#EAF7EC] text-[#1C7C2E] border-[#1C7C2E]/20", -}; - -const STATUS_STYLE = { - ENABLED: "bg-[#F0F8EC] text-[#007600]", - PAUSED: "bg-[#F0F2F2] text-[#565959]", - ARCHIVED: "bg-[#F0F2F2] text-[#565959]", - PENDING_REVIEW: "bg-[#FDF4EC] text-[#C7511F]", -}; - -// --------------------------------------------------------------------------- -// Mock data — stands in for a GET /api/campaigns response shaped from the -// Prisma Campaign model. Swap for real fetch + react-query in production. -// --------------------------------------------------------------------------- -const MOCK_CAMPAIGNS = [ - { - id: "c1", - name: "Summer Sale — Broad Auto", - campaignType: "SP", - subtype: "Auto", - status: "ENABLED", - portfolio: "Kitchen — Core", - budget: 50.0, - impressions: 84210, - clicks: 1320, - ctr: 1.57, - spend: 412.18, - sales: 1840.0, - acos: 22.4, - roas: 4.46, - }, - { - id: "c2", - name: "Hero ASIN — Manual Exact", - campaignType: "SP", - subtype: "Manual · Keyword", - status: "ENABLED", - portfolio: "Kitchen — Core", - budget: 75.0, - impressions: 51900, - clicks: 980, - ctr: 1.89, - spend: 588.0, - sales: 2940.0, - acos: 20.0, - roas: 5.0, - }, - { - id: "c3", - name: "Brand Defense — Branded Terms", - campaignType: "SP", - subtype: "Manual · Keyword", - status: "PAUSED", - portfolio: "Brand Defense", - budget: 20.0, - impressions: 12400, - clicks: 410, - ctr: 3.31, - spend: 96.5, - sales: 720.0, - acos: 13.4, - roas: 7.46, - }, - { - id: "c4", - name: "Holiday Collection — Product Collection", - campaignType: "SB", - subtype: "Product Collection", - status: "ENABLED", - portfolio: "Seasonal", - budget: 40.0, - impressions: 38500, - clicks: 612, - ctr: 1.59, - spend: 301.0, - sales: 1120.0, - acos: 26.9, - roas: 3.72, - }, - { - id: "c5", - name: "New Launch — SB Video", - campaignType: "SB", - subtype: "Video", - status: "PENDING_REVIEW", - portfolio: "Launches", - budget: 30.0, - impressions: 0, - clicks: 0, - ctr: 0, - spend: 0, - sales: 0, - acos: 0, - roas: 0, - }, - { - id: "c6", - name: "Views Remarketing — 30 Day", - campaignType: "SD", - subtype: "Audiences", - status: "ENABLED", - portfolio: "Retargeting", - budget: 25.0, - impressions: 22100, - clicks: 198, - ctr: 0.9, - spend: 144.2, - sales: 410.0, - acos: 35.2, - roas: 2.84, - }, - { - id: "c7", - name: "Competitor Conquest — Contextual", - campaignType: "SD", - subtype: "Contextual", - status: "ARCHIVED", - portfolio: "Retargeting", - budget: 15.0, - impressions: 8900, - clicks: 61, - ctr: 0.69, - spend: 52.4, - sales: 0, - acos: 0, - roas: 0, - }, -]; - -const ALL_COLUMNS = [ - { key: "impressions", label: "Impressions" }, - { key: "clicks", label: "Clicks" }, - { key: "ctr", label: "CTR" }, - { key: "spend", label: "Spend" }, - { key: "sales", label: "Sales" }, - { key: "acos", label: "ACOS" }, - { key: "roas", label: "ROAS" }, -]; - -function fmtCurrency(n) { - return `$${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; -} -function fmtPercent(n) { - return `${n.toFixed(1)}%`; -} - -// --------------------------------------------------------------------------- -// KPI strip -// --------------------------------------------------------------------------- -function KpiStrip({ campaigns }) { - const totals = useMemo(() => { - const live = campaigns.filter((c) => c.status !== "ARCHIVED"); - const spend = live.reduce((a, c) => a + c.spend, 0); - const sales = live.reduce((a, c) => a + c.sales, 0); - const impressions = live.reduce((a, c) => a + c.impressions, 0); - const clicks = live.reduce((a, c) => a + c.clicks, 0); - const acos = sales > 0 ? (spend / sales) * 100 : 0; - const roas = spend > 0 ? sales / spend : 0; - return { spend, sales, acos, roas, impressions, clicks }; - }, [campaigns]); - - const cards = [ - { label: "Spend", value: fmtCurrency(totals.spend) }, - { label: "Sales", value: fmtCurrency(totals.sales) }, - { label: "ACOS", value: fmtPercent(totals.acos) }, - { label: "ROAS", value: totals.roas.toFixed(2) }, - { label: "Impressions", value: totals.impressions.toLocaleString() }, - { label: "Clicks", value: totals.clicks.toLocaleString() }, - ]; - - return ( -
- {cards.map((c) => ( -
-
- {c.label} -
-
- {c.value} -
-
- ))} -
- ); -} - -// --------------------------------------------------------------------------- -// Inline-editable budget cell -// --------------------------------------------------------------------------- -function BudgetCell({ value, onSave }) { - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(value); - - if (!editing) { - return ( - - ); - } - - return ( -
- $ - setDraft(parseFloat(e.target.value) || 0)} - onKeyDown={(e) => { - if (e.key === "Enter") { - onSave(draft); - setEditing(false); - } - if (e.key === "Escape") setEditing(false); - }} - className="h-7 w-20 px-1 text-sm" - /> - - -
- ); -} - -// --------------------------------------------------------------------------- -// Skeleton row (loading state) -// --------------------------------------------------------------------------- -function SkeletonRow() { - return ( - - {Array.from({ length: 9 }).map((_, i) => ( - -
- - ))} - - ); -} - -// --------------------------------------------------------------------------- -// Main component -// --------------------------------------------------------------------------- -export default function CampaignManager() { - const [campaigns, setCampaigns] = useState(MOCK_CAMPAIGNS); - const [loading, setLoading] = useState(true); - const [typeFilter, setTypeFilter] = useState("ALL"); - const [statusFilter, setStatusFilter] = useState("ALL"); - const [search, setSearch] = useState(""); - const [selectedIds, setSelectedIds] = useState([]); - const [visibleCols, setVisibleCols] = useState( - ALL_COLUMNS.reduce((acc, c) => ({ ...acc, [c.key]: true }), {}) - ); - - // Simulates an initial fetch — replace with real data loading. - useEffect(() => { - const t = setTimeout(() => setLoading(false), 600); - return () => clearTimeout(t); - }, []); - - const filtered = useMemo(() => { - return campaigns.filter((c) => { - if (typeFilter !== "ALL" && c.campaignType !== typeFilter) return false; - if (statusFilter !== "ALL" && c.status !== statusFilter) return false; - if (search && !c.name.toLowerCase().includes(search.toLowerCase())) - return false; - return true; - }); - }, [campaigns, typeFilter, statusFilter, search]); - - function toggleStatus(id) { - setCampaigns((prev) => - prev.map((c) => - c.id === id - ? { - ...c, - status: - c.status === "ENABLED" - ? "PAUSED" - : c.status === "PAUSED" - ? "ENABLED" - : c.status, - } - : c - ) - ); - } - - function saveBudget(id, newBudget) { - setCampaigns((prev) => - prev.map((c) => (c.id === id ? { ...c, budget: newBudget } : c)) - ); - } - - function bulkAction(action) { - setCampaigns((prev) => - prev.map((c) => { - if (!selectedIds.includes(c.id)) return c; - if (action === "pause") return { ...c, status: "PAUSED" }; - if (action === "enable") return { ...c, status: "ENABLED" }; - if (action === "archive") return { ...c, status: "ARCHIVED" }; - return c; - }) - ); - setSelectedIds([]); - } - - const allChecked = - filtered.length > 0 && filtered.every((c) => selectedIds.includes(c.id)); - - return ( -
-
-
-

Campaign Manager

-
- - - - {/* Filter row */} -
-
- {["ALL", "SP", "SB", "SD"].map((t) => ( - - ))} -
- - - -
- - setSearch(e.target.value)} - className="h-8 pl-7 w-56 text-[13px]" - /> -
- - - - - - - {ALL_COLUMNS.map((col) => ( - - setVisibleCols((prev) => ({ ...prev, [col.key]: v })) - } - > - {col.label} - - ))} - - - -
- -
-
- - {/* Bulk action bar */} - {selectedIds.length > 0 && ( -
- {selectedIds.length} selected - - - - -
- )} - - {/* Table */} -
- - - - - - - - - - {ALL_COLUMNS.filter((c) => visibleCols[c.key]).map((c) => ( - - ))} - - - - {loading && - Array.from({ length: 5 }).map((_, i) => )} - - {!loading && filtered.length === 0 && ( - - - - )} - - {!loading && - filtered.map((c) => ( - - - - - - - - {visibleCols.impressions && ( - - )} - {visibleCols.clicks && ( - - )} - {visibleCols.ctr && ( - - )} - {visibleCols.spend && ( - - )} - {visibleCols.sales && ( - - )} - {visibleCols.acos && ( - - )} - {visibleCols.roas && ( - - )} - - - ))} - -
- - setSelectedIds(v ? filtered.map((c) => c.id) : []) - } - /> - StatusNameTypePortfolioBudget - {c.label} - -
- No campaigns match these filters.{" "} - -
- - setSelectedIds((prev) => - v ? [...prev, c.id] : prev.filter((id) => id !== c.id) - ) - } - /> - - - -
- toggleStatus(c.id)} - /> -
-
- - - {c.status.replace("_", " ")} - - -
-
- - - - {c.campaignType} - -
- {c.subtype} -
-
{c.portfolio} - saveBudget(c.id, v)} - /> - - {c.impressions.toLocaleString()} - - {c.clicks.toLocaleString()} - - {fmtPercent(c.ctr)} - - {fmtCurrency(c.spend)} - - {fmtCurrency(c.sales)} - - {c.sales > 0 ? fmtPercent(c.acos) : "—"} - - {c.sales > 0 ? c.roas.toFixed(2) : "—"} - - -
-
-
-
- ); -} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index b4b1478..0000000 --- a/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# ---- Build Stage ---- -# Produces a self-contained Next.js standalone bundle in /app/.next/standalone. -# Requires next.config.ts to set `output: 'standalone'` (audit H-12). -FROM node:22-alpine AS builder - -WORKDIR /app - -# Install dependencies (postinstall runs `prisma generate`). -COPY package.json package-lock.json ./ -RUN npm ci - -# Prisma schema and migrations are required for `prisma generate` to succeed -# in the postinstall hook. Without this COPY, the postinstall step crashes -# with "prisma/schema.prisma not found". -COPY prisma/ ./prisma/ - -# Project source -COPY tsconfig.json next.config.ts ./ -COPY src/ ./src/ -COPY public/ ./public/ - -# Build the standalone bundle -RUN npm run build - -# ---- Production Stage ---- -FROM node:22-alpine AS runner - -WORKDIR /app - -ENV NODE_ENV=production -ENV NEXT_TELEMETRY_DISABLED=1 - -# Non-root user for runtime -RUN addgroup --system --gid 1001 nodejs \ - && adduser --system --uid 1001 nextjs - -# Standalone bundle emits a server.js entry point and a `.next/static/` dir -# for client assets. Both must be copied from the builder stage. -COPY --from=builder /app/public ./public -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -USER nextjs - -EXPOSE 3000 - -ENV PORT=3000 -ENV HOSTNAME="0.0.0.0" - -CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index c9fa8a6..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 projectamazonph - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LOOP.md b/LOOP.md deleted file mode 100644 index 9652950..0000000 --- a/LOOP.md +++ /dev/null @@ -1,48 +0,0 @@ -# Loop Configuration — Amazon Ad Console - -## Active Loops - -| Pattern | Cadence | Status | Command | -|---------|---------|--------|---------| -| Daily Triage | 1d | L1 report-only | See README | - -## Human Gates - -- No auto-fix until L2 checklist complete -- All high-risk paths (prisma, auth, env): human review required -- Component refactors: human approval before merge - -## Budget - -- Max sub-agent spawns per run: 0 (L1) / 2 (L2) -- Max tokens/day: 100k (see `loop-budget.md`) -- Append each run to `loop-run-log.md`; use `loop-budget` skill at start/end -- Kill switch: `loop-pause-all` — pause schedulers and notify human - -## Scope - -- Focus on: engine improvements, test coverage, component refactors -- Out of scope: infrastructure changes, deployment config, auth modifications - -## Links - -- State: `STATE.md` -- Constraints: `loop-constraints.md` -- Budget: `loop-budget.md` -- Run log: `loop-run-log.md` - -## Worktree Isolation - -- Use `git worktree add ../amazon-ad-console-fix- ` for unattended experiments -- Never work on main directly — always branch from a worktree -- Clean up worktrees after merge: `git worktree remove ` -- Each worktree gets its own `node_modules` — run `npm install` after creating - -## Skills - -Installed in `skills/` directory: -- `loop-triage` — triages CI, issues, and recent changes -- `loop-verifier` — validates fixes before merging -- `loop-budget` — enforces token/spawn limits at runtime -- `loop-constraints` — reads and enforces safety constraints -- `minimal-fix` — produces smallest possible fix for scoped issues diff --git a/README.md b/README.md index 2f88a90..e215bc4 100644 --- a/README.md +++ b/README.md @@ -1,251 +1,36 @@ -# Amazon Ads Console Training Simulator +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). -> A pixel-faithful Next.js replica of the Amazon Ads Console for training Filipino VAs and eCommerce teams on PPC campaign management — risk-free, offline, with built-in coaching. The UI is a 1:1 reskin of `advertising.amazon.com` with dark slate global nav, grouped left rail, Amazon orange accent, 9 KPI tiles, and responsive mobile layout with hamburger drawer navigation. +## Getting Started -## Quick Start +First, run the development server: ```bash -cd Amazon-ad-console -npm install npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev ``` -Open [http://localhost:3000](http://localhost:3000) — the simulator loads with 6 pre-built training campaigns across SP, SB, and SD: +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -| # | Type | Name | Targeting | -|---|---|---|---| -| 1 | SP | Auto \| Coffee Filter \| Discovery | Automatic | -| 2 | SP | Manual \| Coffee Filter \| Exact Winners | Manual keyword | -| 3 | SB | Video \| Coffee Brand Awareness | Keyword (Video ad format) | -| 4 | SD | Views Remarketing \| 30 Day | Audience | -| 5 | SB | Product Collection \| Coffee Variety | Product targeting | -| 6 | SD | Contextual \| Coffee Accessories | Contextual | +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. -## What You Can Do +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. -### Campaign Management -- **Create** Sponsored Products (SP), Sponsored Brands (SB), and Sponsored Display (SD) campaigns via step-by-step wizard -- **Toggle** campaign status (Enable / Pause / Archive) -- **Duplicate** campaigns to experiment without losing originals -- **Delete** (archive) campaigns -- **Adjust** daily budgets, default bids, bid strategies, and placement modifiers +## Learn More -### Keyword & Target Operations -- **Add keywords** with Exact, Phrase, or Broad match types at custom bids -- **Remove keywords** (pause / delete targets) -- **Adjust bids** — set exact bid or use ±multiplier -- **Add negative keywords** — Negative exact and Negative phrase -- **Harvest** converting search terms into new targets +To learn more about Next.js, take a look at the following resources: -### Portfolios & Organizing -- **View** campaigns grouped by portfolio -- **Filter** by campaign type (SP/SB/SD), status, portfolio, and free-text search +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. -### Metrics & Reporting -- **Dashboard** — aggregate metrics across all enabled campaigns -- **Campaign view** — metrics roll up from targets → ad groups → campaign -- **Ad group view** — individual ad group performance -- **Keyword/target view** — per-keyword metrics (impressions, clicks, spend, sales, orders) -- **Derived KPIs** — CTR, CPC, ACoS, ROAS, CVR computed at every level -- **Reports** — generate and export campaign / ad group / target / search term / placement reports as CSV +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! -### Simulation -- **Run 7-day simulation** — generates realistic performance data across all enabled campaigns -- Metrics cascade correctly: keyword → ad group → campaign → dashboard +## Deploy on Vercel -### Training Features -- **Training** global nav section in the topbar exposes all 6 training-product pages. -- **Drills** — click-by-click navigation coaching with mistake tracking -- **Missions** — scenario-based challenges (Beginner → Advanced) with scoring and hints -- **Reports** — generate and export campaign / target / search-term / placement reports as CSV -- **Bulk Operations** — paste Amazon Ads bulk CSV for validation and preview -- **Trainer Dashboard** — certification checklist, action grading, notes -- **Integrity Center** — automated data-quality checks (orphaned terms, duplicate IDs, creative issues) -- **Multi-User Profiles** — separate training state per trainee +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. -### Multi-User Access -- **User Registration** — create account with email/password -- **Login/Logout** — secure session management via NextAuth -- **Cloud Sync** — save/load campaigns to database -- **Per-User Data** — each user has isolated campaign data - -## Tech Stack - -| Layer | Technology | -|-------|-----------| -| Framework | Next.js 16 (App Router) | -| UI | React 19 | -| State | Zustand 5 (single store, 8 core slices + 7 feature slices) | -| Language | TypeScript 5.8 (strict mode) | -| UI components | `@astryxdesign/core` (153 components, theme via `@astryxdesign/theme-neutral`) | -| Styling | Global CSS tokens bridging Astryx to the Amazon-faithful visual identity | -| Engine | Pure TypeScript — zero React/UI dependencies | -| Database | Prisma 7 + Postgres (via `@prisma/adapter-neon`) | -| Authentication | NextAuth v5 (credentials provider) | -| Password Hashing | bcryptjs | - -## Testing - -This project uses **TDD** for the pure business logic in `src/engine/ad-console/core`. -Tests live next to the code they cover (`*.test.ts`) and run on Vitest. - -```bash -npm test # run the suite once -npm run test:watch -``` - -### Principles enforced by the suite -- **Fail fast** — invalid inputs throw `ValidationError` instead of silently - clamping or producing `NaN` (see `src/lib/validation.ts`). Covered by tests - for `calc`, `normalizeCampaign`, and `addTarget`. -- **Single responsibility** — each engine function transforms one thing and - returns new state; no hidden side effects. -- **DRY / KISS / YAGNI** — shared guards live in one `validation.ts` module; - no duplicate clamping or re-validation across functions. - -To add a feature: write a failing test in `src/engine/ad-console/core/__tests__/`, -then implement the function until green. Keep logic in the engine layer so it -stays framework-free and unit-testable. - -## Project Structure - -``` -Amazon-ad-console/ -├── src/ -│ ├── app/ # Next.js App Router -│ │ ├── layout.tsx # Root layout + metadata + SessionProvider -│ │ ├── page.tsx # Home → -│ │ ├── landing/page.tsx # Landing page with auth links -│ │ ├── auth/ -│ │ │ ├── login/page.tsx # Login page -│ │ │ └── register/page.tsx # Registration page -│ │ ├── api/ -│ │ │ ├── auth/ # NextAuth API routes -│ │ │ │ ├── [...nextauth]/route.ts -│ │ │ │ └── register/route.ts -│ │ │ ├── campaigns/ # Campaign CRUD API -│ │ │ │ ├── route.ts # GET/POST campaigns -│ │ │ │ └── [id]/route.ts # GET/PUT/DELETE single campaign -│ │ │ └── sync/route.ts # Bulk sync campaigns to/from DB -│ │ └── globals.css # Premium design system tokens + styles -│ ├── engine/ # Portable business logic -│ │ └── ad-console/ -│ │ ├── core/ # Zero-dep engine -│ │ │ ├── types.ts # All domain interfaces -│ │ │ ├── engine/ # Pure stateless functions, one module per domain concern -│ │ │ │ └── (campaign, target, adgroup, negative, budget, portfolio, draft, id, metrics, responsive, search-term-generator).ts -│ │ │ ├── simulation.ts # 7-day performance simulator -│ │ │ ├── slices/ # Zustand StateCreator slices wrapping the engine -│ │ │ └── scenarios.ts # Training data & product catalog -│ │ ├── features/ # 7 self-contained feature modules -│ │ │ ├── drills/ # Navigation coaching -│ │ │ ├── profiles/ # Multi-user profiles -│ │ │ ├── trainer/ # Certification & grading -│ │ │ ├── bulk/ # CSV import/validate -│ │ │ ├── reports/ # Report generation & export -│ │ │ ├── missions/ # Scenario challenges -│ │ │ └── integrity/ # Data quality checks -│ │ ├── store.ts # Composed root Zustand store -│ │ ├── index.ts # Public API re-exports -│ │ ├── types.ts # Backward-compat re-export of core/types.ts -│ │ └── scenarios.ts # Backward-compat re-export of core/scenarios.ts -│ ├── components/ -│ │ ├── AdConsole/ # React UI layer -│ │ │ ├── AdConsole.tsx # Root view router -│ │ │ ├── Dashboard.tsx # Aggregate metrics -│ │ │ ├── CampaignManager.tsx # Campaign list + filters -│ │ │ ├── CampaignDetail.tsx # Single campaign deep-dive -│ │ │ ├── PortfolioOverview.tsx # Portfolio grouping -│ │ │ ├── wizard/ # 6-step campaign creation flow (per SP/SB/SD) -│ │ │ │ └── CreateCampaignWizard.tsx -│ │ │ ├── layout/ -│ │ │ │ ├── Sidebar.tsx # Desktop navigation rail -│ │ │ │ └── Topbar.tsx # Header with actions + UserMenu -│ │ │ ├── mobile/ -│ │ │ │ └── MobileNav.tsx # Mobile/tablet hamburger drawer navigation -│ │ │ ├── nav/ -│ │ │ │ └── consoleNav.ts # Amazon console nav model -│ │ │ ├── metrics/ -│ │ │ │ └── MetricCard.tsx # Reusable metric display -│ │ │ ├── details/ # Tab components + shared EmptyState -│ │ │ └── features/ # Feature-specific pages -│ │ │ ├── drills/DrillsPage.tsx -│ │ │ ├── missions/MissionsPage.tsx -│ │ │ ├── reports/ReportsPage.tsx -│ │ │ ├── bulk/BulkOpsPage.tsx -│ │ │ ├── trainer/TrainerPage.tsx -│ │ │ └── integrity/IntegrityPage.tsx -│ │ ├── SessionProvider.tsx # NextAuth session wrapper -│ │ ├── UserMenu.tsx # User dropdown menu -│ │ └── SyncButton.tsx # Cloud sync controls -│ ├── lib/ -│ │ ├── auth.ts # NextAuth configuration -│ │ ├── prisma.ts # Prisma client singleton -│ │ ├── validation.ts # Input validation helpers -│ │ └── useBreakpoint.ts # Responsive breakpoint hook -│ └── generated/prisma/ # Prisma generated client -├── prisma/ -│ ├── schema.prisma # Database schema (User, Campaign, Simulation) -│ └── migrations/ # Database migrations -├── docs/ # Project documentation -│ ├── ARCHITECTURE.md -│ ├── API.md -│ ├── SCHEMA.md -│ ├── FEATURES.md -│ ├── INTEGRATION.md -│ ├── TECH-SPECS.md -│ ├── MOBILE_REDESIGN_PLAN.md -│ ├── AUTH.md # Multi-user authentication guide -│ ├── DEPLOYMENT.md # Vercel project setup and deploy process -│ └── AUDIT-FOLLOWUPS.md # Status of each audit finding, with PR links -├── CLAUDE.md # Architecture + conventions guide for Claude Code -├── CHANGELOG.md # Notable changes per release -├── .env.example # DATABASE_URL / AUTH_SECRET template -├── package.json -├── tsconfig.json -├── next.config.ts -└── prisma.config.ts # Prisma configuration -``` - -## Scripts - -| Command | Description | -|---------|------------| -| `npm run dev` | Start dev server on port 3000 | -| `npm run build` | Production build | -| `npm start` | Start production server | -| `npm run lint` | Run Next.js linter | -| `npm run type-check` | TypeScript type checking | -| `npx prisma migrate dev` | Run database migrations | -| `npx prisma generate` | Generate Prisma client | - -## Porting to amph-v2 - -The entire engine layer (`src/engine/ad-console/`) is portable with zero changes: - -```ts -// In amph-v2, copy the engine folder and import: -import { useAdConsoleStore } from '@/engine/ad-console/store'; -// or use the core engine standalone: -import { calc, simulateDays } from '@/engine/ad-console/core/engine'; -``` - -See [docs/INTEGRATION.md](docs/INTEGRATION.md) for the full porting guide. - -## Documentation - -- [Architecture](docs/ARCHITECTURE.md) — SOLID design, slice composition, data flow -- [API Reference](docs/API.md) — All engine functions with signatures -- [Data Schema](docs/SCHEMA.md) — TypeScript interfaces and data shapes -- [Features](docs/FEATURES.md) — Detailed feature documentation -- [Integration Guide](docs/INTEGRATION.md) — Porting to amph-v2 -- [Tech Specs](docs/TECH-SPECS.md) — Dependencies, configuration, performance -- [Mobile Redesign Plan](docs/MOBILE_REDESIGN_PLAN.md) — Mobile-first redesign strategy -- [Authentication Guide](docs/AUTH.md) — Multi-user access setup and configuration -- [Deployment](docs/DEPLOYMENT.md) — Vercel project setup and deploy process -- [Audit Follow-Ups](docs/AUDIT-FOLLOWUPS.md) — Status of each finding from the 2026-07-21 audit, with PR links -- [Changelog](CHANGELOG.md) — Notable changes per release, starting at 3.6.0 - -## License - -MIT +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/STATE.md b/STATE.md deleted file mode 100644 index b0e6999..0000000 --- a/STATE.md +++ /dev/null @@ -1,17 +0,0 @@ -# Loop State — Amazon Ad Console - -Last run: never - -## High Priority (loop is acting or waiting on human) - -## Watch List - -- Component layer SOLID violations (CreateCampaignWizard, CampaignManager, CampaignDetail) -- 312+ engine tests passing — maintain coverage - -## Recent Noise (ignored this run) - -## Post-Run Critique (from last run) - ---- -Run log: — diff --git a/TDD-SOLID-Implementation-Plan.md b/TDD-SOLID-Implementation-Plan.md deleted file mode 100644 index 1dd65f5..0000000 --- a/TDD-SOLID-Implementation-Plan.md +++ /dev/null @@ -1,360 +0,0 @@ -""""Amazon Ad Console Entity Relationship Refactoring - TDD Implementation Plan - -## Test-Driven Development (TDD) + SOLID Principles Implementation Plan - -This document outlines a comprehensive Test-Driven Development (TDD) approach to refactoring the Amazon Ad Console entity relationships, following SOLID architectural principles. - ---- - -## I. TDD Philosophy & SOLID Principles Overview - -### A. TDD (Test-Driven Development) -- **Red-Green-Refactor Cycle**: Write failing tests first, then implement to make them pass, then refactor -- **Continuous Feedback**: Immediate validation of changes -- **Specification by Tests**: Tests serve as requirements documentation -- **Early bug detection**: Find issues during development, not deployment - -### B. SOLID Principles Compliance - -#### **Single Responsibility Principle (SRP)** -- **Rule**: Each class/function should have only one reason to change -- **Application**: Separate entity concerns (types, validation, business logic) - -#### **Open/Closed Principle (OCP)** -- **Rule**: Software entities should be open for extension, closed for modification -- **Application**: Strategy patterns for match types, extensible validation - -#### **Liskov Substitution Principle (LSP)** -- **Rule**: Subtypes should be replaceable for their base types -- **Application**: Shared interfaces across entity types - -#### **Interface Segregation Principle (ISP)** -- **Rule**: Clients should not depend on interfaces they don't use -- **Application**: Focused, purpose-specific interfaces - -#### **Dependency Inversion Principle (DIP)** -- **Rule**: Depend on abstractions, not concretizations -- **Application**: Abstract base classes for core logic - ---- - -## II. Story-Based Implementation Plan - -### Story 1: Core Entity Type System -**Goal**: Establish proper TypeScript entity types following SOLID principles - -#### **Before Implementation (Red Phase)** -```typescript -// TEST: Verify campaign entity structure -it('Campaign should have all required properties', () => { - const c = createMinimalCampaign(); - expect(c.id).toBeDefined(); - expect(c.type).toBeDefined(); - expect(c.name).toBeDefined(); - expect(c.portfolio).toBeDefined(); - expect(c.status).toBeDefined(); - expect(c.dailyBudget).toBeGreaterThan(0); - expect(c.defaultBid).toBeGreaterThan(0); - expect(c.startDate).toBeDefined(); - expect(c.endDate).toBe(null); - expect(c.targetingMode).toBeDefined(); - expect(c.adFormat).toBeDefined(); - expect(c.bidStrategy).toBeDefined(); - expect(c.placements).toBeDefined(); - expect(c.products).toBeDefined(); - expect(c.creative).toBeDefined(); - expect(c.metrics).toBeDefined(); - expect(c.adGroups).toBeDefined(); - expect(c.targets).toBeDefined(); - expect(c.searchTerms).toBeDefined(); - expect(c.negatives).toBeDefined(); - expect(c.budgetRules).toBeDefined(); - - // NEW: Should have these properties - expect(c.productAds).toBeDefined(); - expect(c.ads).toBeDefined(); -}); -``` - -#### **After Implementation (Green Phase)** -```typescript -// Refactored: use factory function with proper defaults -const campaign = normalizeCampaign({ - type: 'SP', - name: 'Test Campaign', - // All required properties automatically set by factory -}); - -expect(campaign.type).toBe('SP'); -expect(campaign.productAds).toEqual([]); -expect(campaign.ads).toEqual([]); -``` - -### Story 2: Target Entity Refactoring -**Goal**: Support all target types following SOLID principles - -#### **SRP - Single Responsibility** -- Target entity: Manage single target data and operations -- Target factory: Create different target types -- Target validation: Validate target-specific rules - -#### **OCP - Extensibility** -- MatchType strategy pattern for future match types -- TargetType strategy pattern for future target types -- Easy to add new target types without modifying existing code - -#### **LSP - Substitutability** -- All target types implement common Target interface -- Function parameters accept any Target subtype - ---- - -### Story 3: Negative Entity System -**Goal**: Advanced negative filtering with proper entity hierarchy - -#### **ISP - Segregated Interfaces** -- Negative operations separated from target operations -- Campaign-level and ad-group-level negatives distinct -- Search term harvesting as separate concern - -#### **DIP - Dependency Management** -- Business logic independent of UI -- Validation abstracted from business rules - ---- - -### Story 4: AdGroup Operations -**Goal**: Complete ad group management following SRP - -#### **SRP - Single Responsibility** -- AdGroup creation: Single responsibility for ad group setup -- Target management: Separate operations for target-related tasks -- Bid management: Focus on bid-related operations - ---- - -### Story 5: Integration Testing -**Goal**: Validate cross-entity relationships - -#### **OCP - Open for Extension** -- New entity types should be testable without modifying tests -- Extensible test scenarios for complex interactions - ---- - -## III. Test Development Strategy - -### **A. Test Classification** - -#### **1. Unit Tests** -- Individual entity constructors -- Validation functions -- Business logic operations -- Match type generators - -#### **2. Integration Tests** -- Cross-entity operations -- State management tests -- Error handling scenarios - -#### **3. End-to-End Tests** -- Complete user workflows -- Complex scenarios -- Performance validation - ---- - -### **B. Test Organization** -``` -/src/engine/ad-console/core/ - /__tests__/ # Test suite root - ├── adgroup.test.ts # AdGroup functionality - ├── adgroup.test.ts # AdGroup functionality (TypeScript fixed) - ├── budget-rules.test.ts # Budget rule operations - ├── campaignGoal.test.ts # Campaign goal validation - ├── engine.test.ts # Core integration tests - ├── portfolio.test.ts # Portfolio functionality - ├── simulation.test.ts # Simulation operations - └── slices.test.ts # Store slice functionality - -/src/engine/ad-console/features/ - /integrity/ - └── __tests__/engine.test.ts # Integrity validation tests -``` - ---- - -## IV. Implementation Schedule (TDD Cycle) - -### **Sprint 1: Foundation Tests** -**Duration:** 1 week -**Focus:** Entity type system setup - -#### **Before Sprint:** -- Analyze current test failures from TypeScript errors -- Identify missing entity properties -- Document current behavior vs desired behavior - -#### **During Sprint:** -1. **Day 1-2:** Write tests for campaign entity structure -2. **Day 3-4:** Write tests for target entity structure -3. **Day 5-6:** Write tests for negative entity structure -4. **Day 7:** Review test results, fix failures - -#### **After Sprint:** -- Baseline test suite passes -- Entity types properly defined -- Foundation for all subsequent stories - -### **Sprint 2: Core Functionality** -**Duration:** 2 weeks -**Focus:** Business logic implementation - -#### **Before Sprint:** -- Comprehensive test suite for core operations -- All entity type tests passing - -#### **During Sprint:** -1. **Week 1:** Implement campaign business logic -2. **Week 2:** Implement target and negative business logic -3. **Continuous:** Test validation after each iteration - -### **Sprint 3: Integration & Store Updates** -**Duration:** 1 week -**Focus:** Component and store integration - -#### **Before Sprint:** -- All core business logic implemented and tested -- Component interfaces defined - -#### **During Sprint:** -1. **Day 1-2:** Update store slices with new entity types -2. **Day 3:** Component hook updates -3. **Day 4-5:** Integration testing - -### **Sprint 4: Validation & Refinement** -**Duration:** 1 week -**Focus:** Testing, documentation, optimization - -#### **Before Sprint:** -- All features implemented -- Basic integration working - -#### **During Sprint:** -1. **Week 1:** Full test suite execution -2. **Week 2:** Manual testing of user workflows -3. **Week 3:** Performance optimization -4. **Week 4:** Documentation updates - ---- - -## V. Quality Gates - -### **A. Technical Quality Gates** - -#### **1. TypeScript Compilation** -- **Entry**: Every sprint must pass `npx tsc --noEmit` -- **Acceptance**: Zero type errors -- **Validation**: Generated code compiles with no issues - -#### **2. Test Suite Execution** -- **Entry**: Every sprint must have comprehensive test coverage -- **Acceptance**: All tests pass -- **Validation**: Coverage meets quality standards - -#### **3. SOLID Principles Compliance** -- **SRP**: Each entity type/function has single responsibility -- **OCP**: Code extensible for future enhancements -- **LSP**: Subtypes substitutable for base types -- **ISP**: Focused, specific interfaces -- **DIP**: Dependencies on abstractions, not concretions - ---- - -## VI. Risk Management - -### **A. TypeScript Risks** -- **Risk**: Breaking existing functionality -- **Mitigation**: Gradual rollout with comprehensive testing -- **Backup**: Version control, rollback capabilities - -### **B. Test Coverage Risks** -- **Risk**: Incomplete test scenarios -- **Mitigation**: Test-driven development ensures coverage -- **Backup**: Manual testing for complex scenarios - -### **C. Performance Risks** -- **Risk**: Performance degradation -- **Mitigation**: Profiling and optimization -- **Backup**: Performance budgets and monitoring - ---- - -## VII. Monitoring & Metrics - -### **A. Technical Metrics** -- **Test Coverage**: % of codebase covered -- **Type Safety**: Error count -- **Build Success Rate**: Successful compilation rate -- **Code Complexity**: Cyclomatic complexity metrics - -### **B. Business Metrics** -- **Developer Productivity**: Time to implement features -- **User Experience**: Task completion rates -- **System Reliability**: Error rates -- **Training Efficiency**: Time to competence - ---- - -## VIII. Rollback Strategy - -### **A. Immediate Rollback** -- **Version control tags**: Every build version tagged -- **Feature flags**: Optional feature toggles -- **Configuration management**: Externalized configuration - -### **B. Gradual Rollout** -- **Feature flags**: Canary releases -- **A/B testing**: Controlled experiment groups -- **Gradual deployment**: Phased rollout to production - ---- - -## IX. Documentation - -### **A. Technical Documentation** -- **API Documentation**: Complete function interfaces -- **Entity Documentation**: Detailed type definitions -- **Architecture Documentation**: SOLID principle application -- **Testing Documentation**: Test suite documentation - -### **B. User Documentation** -- **User Guides**: Feature usage guides -- **Quick References**: Common operation shortcuts -- **Troubleshooting**: Error resolution guides -- **Examples**: Real-world usage scenarios - ---- - -## X. Conclusion - -This TDD + SOLID implementation plan ensures: - -1. **Quality**: Each story follows clear requirements -2. **Testability**: Comprehensive test coverage -3. **Maintainability**: SOLID principles ensure long-term maintainability -4. **Extensibility**: Open for future enhancements -5. **Reliability**: Continuous testing ensures stability - -The approach delivers a robust, well-tested Amazon Ad Console that properly implements Amazon Advertising API entity relationships while providing excellent developer and user experience. - ---- - -**Next Steps:** -1. Begin Sprint 1 test development -2. Establish foundation for all subsequent stories -3. Implement incrementally with continuous testing -4. Validate SOLID compliance at each step -5. Deliver production-ready, well-tested solution - -"""" \ No newline at end of file diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..3579145 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect } from "react"; + +export default function ErrorBoundary({ + error, + unstable_retry, +}: { + error: Error & { digest?: string }; + unstable_retry: () => void; +}) { + useEffect(() => { + // Surface to the console for dev visibility; wire to real telemetry later. + if (process.env.NODE_ENV !== "production") { + console.error("[app/error] route error:", error); + } + }, [error]); + + return ( +
+
+
+ + Something went wrong +
+

+ The page hit an error +

+

+ The Next.js shell couldn't render this route. The legacy + simulator is unaffected. +

+ +
+          {error.message}
+          {error.digest ? `\n\ndigest: ${error.digest}` : ""}
+        
+ +
+ + + Open legacy simulator + +
+
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..52e8312 --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect } from "react"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +// global-error replaces the root layout, so it must define its own / +// and pull in its own styles/fonts. Metadata export is not allowed here; we set +// via the React component. +export default function GlobalError({ + error, + unstable_retry, +}: { + error: Error & { digest?: string }; + unstable_retry: () => void; +}) { + useEffect(() => { + if (process.env.NODE_ENV !== "production") { + console.error("[app/global-error] root layout error:", error); + } + }, [error]); + + return ( + <html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}> + <body className="min-h-full bg-white font-sans text-zinc-900 antialiased dark:bg-black dark:text-zinc-50"> + <div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-zinc-50 to-white px-6 font-sans dark:from-black dark:to-zinc-950"> + <div className="w-full max-w-lg rounded-2xl border border-red-200 bg-white p-8 shadow-sm dark:border-red-900/50 dark:bg-zinc-950"> + <div className="inline-flex items-center gap-2 rounded-full border border-red-200 bg-red-50 px-3 py-1 text-xs font-medium text-red-700 dark:border-red-900/50 dark:bg-red-950/40 dark:text-red-300"> + <span className="size-1.5 rounded-full bg-red-500" /> + Something went wrong + </div> + <h1 className="mt-4 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50"> + The page hit an error + </h1> + <p className="mt-2 text-sm leading-6 text-zinc-600 dark:text-zinc-400"> + The Next.js shell couldn't render this route. The legacy + simulator is unaffected. + </p> + + <pre className="mt-4 max-h-40 overflow-auto rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-xs text-zinc-700 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300"> + {error.message} + {error.digest ? `\n\ndigest: ${error.digest}` : ""} + </pre> + + <div className="mt-6 flex flex-wrap gap-3"> + <button + type="button" + onClick={() => unstable_retry()} + className="inline-flex h-10 items-center rounded-full bg-zinc-900 px-5 text-sm font-medium text-zinc-50 transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200" + > + Try again + </button> + <a + href="/adconsole.html" + className="inline-flex h-10 items-center rounded-full border border-zinc-300 px-5 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-50 dark:hover:bg-zinc-900" + > + Open legacy simulator + </a> + </div> + </div> + </div> + </body> + </html> + ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..a2dc41e --- /dev/null +++ b/app/globals.css @@ -0,0 +1,26 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..d277f83 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: { + default: "AdConsole", + template: "%s · AdConsole", + }, + description: "Amazon PPC teaching simulator", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + <html + lang="en" + className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} + > + <body className="min-h-full flex flex-col">{children}</body> + </html> + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..9cbd636 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "Page not found", + robots: { index: false, follow: false }, +}; + +export default function NotFound() { + return ( + <div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-zinc-50 to-white px-6 font-sans dark:from-black dark:to-zinc-950"> + <div className="w-full max-w-md text-center"> + <p className="text-sm font-medium tracking-wide text-zinc-500 dark:text-zinc-500"> + 404 + </p> + <h1 className="mt-2 text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50"> + Page not found + </h1> + <p className="mt-3 text-sm leading-6 text-zinc-600 dark:text-zinc-400"> + The route you tried doesn't exist. The legacy simulator is still + available at <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-sm dark:bg-zinc-900">/adconsole.html</code>. + </p> + <div className="mt-6 flex flex-wrap items-center justify-center gap-3"> + <Link + href="/" + className="inline-flex h-10 items-center rounded-full bg-zinc-900 px-5 text-sm font-medium text-zinc-50 transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200" + > + ← Back to home + </Link> + <a + href="/adconsole.html" + className="inline-flex h-10 items-center rounded-full border border-zinc-300 px-5 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-50 dark:hover:bg-zinc-900" + > + Open legacy simulator + </a> + </div> + </div> + </div> + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..3c7303b --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,119 @@ +import type { Metadata } from "next"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +export const metadata: Metadata = { + title: "AdConsole", + description: "Amazon PPC teaching simulator", +}; + +async function getLegacyStats() { + try { + const filePath = path.join(process.cwd(), "public", "adconsole.html"); + const stat = await fs.stat(filePath); + return { + bytes: stat.size, + kb: Math.round(stat.size / 1024), + modified: stat.mtime.toLocaleString("sv-SE"), + }; + } catch (e) { + if (process.env.NODE_ENV !== "production") { + console.warn("adconsole.html not found:", e); + } + return null; + } +} + +export default async function Home() { + const stats = await getLegacyStats(); + + return ( + <div className="min-h-screen bg-gradient-to-b from-zinc-50 to-white dark:from-black dark:to-zinc-950"> + <main className="mx-auto flex min-h-screen w-full max-w-5xl flex-col items-stretch justify-center gap-10 px-6 py-16 sm:px-10"> + <header className="flex flex-col gap-3"> + <span className="inline-flex w-fit items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-950/40 dark:text-emerald-300"> + <span className="size-1.5 rounded-full bg-emerald-500" /> + Next.js shell ready + </span> + <h1 className="text-4xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50 sm:text-5xl"> + AdConsole + </h1> + <p className="max-w-2xl text-base leading-7 text-zinc-600 dark:text-zinc-400"> + Amazon PPC teaching simulator. The legacy static page is preserved + at <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-sm dark:bg-zinc-900">/adconsole.html</code>{" "} + while we port it into React components. Pick a path below to keep + moving. + </p> + </header> + + <section className="grid gap-4 sm:grid-cols-2"> + <a + href="/adconsole.html" + className="group flex flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 transition-all hover:border-zinc-300 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-950 dark:hover:border-zinc-700" + > + <div className="flex items-center justify-between"> + <h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50"> + Open legacy page + </h2> + <span className="text-zinc-400 transition-transform group-hover:translate-x-0.5"> + → + </span> + </div> + <p className="text-sm leading-6 text-zinc-600 dark:text-zinc-400"> + Jump straight into the original AdConsole HTML — works exactly as + before, no rebuild required. + </p> + {stats && ( + <dl className="mt-2 grid grid-cols-2 gap-3 border-t border-zinc-100 pt-3 text-xs text-zinc-500 dark:border-zinc-900"> + <div> + <dt className="font-medium text-zinc-400">Size</dt> + <dd className="text-zinc-700 dark:text-zinc-300"> + {stats.kb} KB + </dd> + </div> + <div> + <dt className="font-medium text-zinc-400">Last modified</dt> + <dd className="text-zinc-700 dark:text-zinc-300"> + {stats.modified} + </dd> + </div> + </dl> + )} + </a> + + <div className="flex flex-col gap-3 rounded-2xl border border-dashed border-zinc-300 bg-white/50 p-6 dark:border-zinc-800 dark:bg-zinc-950/40"> + <h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50"> + Build the React port + </h2> + <p className="text-sm leading-6 text-zinc-600 dark:text-zinc-400"> + Start migrating the simulator into <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-sm dark:bg-zinc-900">app/</code>{" "} + route by route. The static HTML is the source of truth until the + port lands. + </p> + <ul className="mt-2 space-y-1.5 text-sm text-zinc-600 dark:text-zinc-400"> + <li className="flex items-center gap-2"> + <span className="size-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" /> + <code>app/page.tsx</code> — landing shell + </li> + <li className="flex items-center gap-2"> + <span className="size-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" /> + <code>app/simulator/page.tsx</code> — main UI (TBD) + </li> + <li className="flex items-center gap-2"> + <span className="size-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" /> + <code>public/adconsole.html</code> — legacy reference + </li> + </ul> + </div> + </section> + + <footer className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-zinc-200 pt-6 text-xs text-zinc-500 dark:border-zinc-900"> + <span> + Next.js · App Router · TypeScript · Tailwind v4 + </span> + <span>Run <code className="rounded bg-zinc-100 px-1.5 py-0.5 dark:bg-zinc-900">npm run dev</code> to start</span> + </footer> + </main> + </div> + ); +} diff --git a/codegraphs/Amazon-ad-console.md b/codegraphs/Amazon-ad-console.md deleted file mode 100644 index da7f9c0..0000000 --- a/codegraphs/Amazon-ad-console.md +++ /dev/null @@ -1,29 +0,0 @@ -# Amazon-ad-console - Code Dependency Graph - -This project is a single-file, offline HTML training simulator. The app and its -inline script live entirely inside `amazon_ppc_simulator.html`. Supporting files -are documentation and a Node-based QA harness. - -```mermaid -graph TD - APP["amazon_ppc_simulator.html<br/>(inline HTML + CSS + JS)"] - CHECK["amazon_ppc_simulator_check.js<br/>(extracted script for syntax check)"] - QA["amazon_ppc_simulator_v3_3_qa.js<br/>(Node VM QA harness)"] - RESULTS["amazon_ppc_simulator_v3_3_qa_results.json"] - DOC["amazon_ppc_simulator_v3_3_documentation.md"] - CHANGELOG["amazon_ppc_simulator_v3_3_changelog.md"] - REPORT["amazon_ppc_simulator_v3_3_qa_report.md"] - MANIFEST["amazon_ppc_simulator_v3_3_release_manifest.json"] - README["README.md"] - - QA -->|reads inline script| APP - QA -->|writes| RESULTS - CHECK -.->|mirrors app script| APP - README --> APP - README --> DOC - README --> QA - DOC --> APP - CHANGELOG --> APP - REPORT --> QA - MANIFEST --> APP -``` diff --git a/commit_msg.md b/commit_msg.md deleted file mode 100644 index fd96cfb..0000000 --- a/commit_msg.md +++ /dev/null @@ -1,81 +0,0 @@ -feat(phase-5): migrate .table-wrap to Astryx Table (theme-preserving) - -Phase 5 of the 13-phase Astryx migration. Every `<div className="table-wrap"><table>...</table></div>` in the codebase is now an Astryx `<Table>` (children mode) with the Amazon platform look preserved via the Phase 1 theme bridge. - -### How the migration maps - -| Old | New | -|---|---| -| `<div className="table-wrap"><table>...</table></div>` | `<Table>...</Table>` (children mode keeps raw `<thead>`/`<tbody>`/`<tr>`/`<th>`/`<td>` JSX intact) | - -### Why children mode, not the data-driven API - -The existing tables carry a lot of inline content (raw `<input>` for bid edits, Astryx `<Button>` for actions, pills, formatted text). The data-driven API would force every row's `renderCell` to be a closure over local state, which is a much bigger refactor with no functional gain for the look-and-feel migration. Children mode keeps the same DOM output but emits the stable `.astryx-table` and `.astryx-table-scroll-wrapper` classes that we can theme through the bridge. - -### Bridge CSS - -`src/app/astryx-theme.css` gains a `.astryx-table-scroll-wrapper` block that gives the new wrapper the same card-in-table look the old `.table-wrap` had: `overflow-x: auto`, `--radius-lg` corner, soft border, surface background, plus a `--radius-md` override at 900px for mobile. The existing global `th`/`td`/`tr:hover`/`.mono`/`.money` rules in `globals.css` already style the cells (sticky header, padding, hover rows, tabular-nums) and apply unchanged to the `<th>`/`<td>` inside the Astryx Table. - -### globals.css cleanup - -The legacy `.table-wrap` rule is removed (the bridge now provides the equivalent styling under the new class name). The `@media (max-width: 900px)` responsive override is updated to target `.astryx-table-scroll-wrapper`. - -### A11y contract (preserved) - -- All `<th>`/`<td>` elements continue to render with their `htmlFor`/`id` wiring where applicable. -- Inline `<TextInput>`/`<NumberInput>`/`<Selector>` inside table cells (Phases 4 inputs) remain functional and labelled. -- The scroll wrapper exposes `role="group"` and `aria-label="@astryx.table.label"` from Astryx (the default label is fine for plain data tables; it can be overridden per-table later if needed). - -### `tables-astryx.test.tsx` (NEW) — 12 contract tests - -- `.astryx-table` is rendered inside `.astryx-table-scroll-wrapper` -- No leftover `.table-wrap` class in DOM -- No leftover `.table-wrap` rule in globals.css -- `.astryx-table-scroll-wrapper` has `overflow-x: auto` via bridge CSS -- `.astryx-table-scroll-wrapper` has border + radius -- `<th>` elements exist and are non-empty -- `<th>` sticky positioning is still defined -- `<th>` uppercase + letter-spacing still defined -- `.mono` tabular-nums still defined -- `tr:hover td` background rule still defined -- `td` padding rule still defined -- `tr:last-child td` no-border rule still defined - -`tables.test.tsx` (Phase 0) **deleted** — the pre-migration `.table-wrap` contract is obsolete. - -### Files changed (19 .tsx + 2 .css + 1 new test) - -- `src/components/AdConsole/__tests__/contracts/tables-astryx.test.tsx` (**NEW**) — 12 contract tests -- `src/components/AdConsole/Dashboard.tsx` — 1 table -- `src/components/AdConsole/PortfolioOverview.tsx` — 1 table -- `src/components/AdConsole/details/AdGroupsTab.tsx` — 2 tables -- `src/components/AdConsole/details/BudgetRulesTab.tsx` — 1 table -- `src/components/AdConsole/details/ManagerAdGroupsTab.tsx` — 1 table -- `src/components/AdConsole/details/ManagerCampaignsTab.tsx` — 1 table -- `src/components/AdConsole/details/ManagerNegativesTab.tsx` — 1 table -- `src/components/AdConsole/details/ManagerSearchTermsTab.tsx` — 1 table -- `src/components/AdConsole/details/ManagerTargetsTab.tsx` — 1 table -- `src/components/AdConsole/details/NegativesTab.tsx` — 1 table -- `src/components/AdConsole/details/OverviewTab.tsx` — 1 table -- `src/components/AdConsole/details/SearchTermsTab.tsx` — 1 table -- `src/components/AdConsole/details/TargetsTab.tsx` — 1 table -- `src/components/AdConsole/features/bulk/BulkOpsPage.tsx` — 1 table -- `src/components/AdConsole/features/drills/DrillsPage.tsx` — 1 table -- `src/components/AdConsole/features/reports/ReportsPage.tsx` — 1 table -- `src/components/AdConsole/features/trainer/TrainerPage.tsx` — 1 table -- `src/components/AdConsole/wizard/steps/sb/Step3ProductsCreative.tsx` — 1 table -- `src/app/astryx-theme.css` — added `.astryx-table-scroll-wrapper` bridge block -- `src/app/globals.css` — removed `.table-wrap` rule, updated `@media` override -- `src/components/AdConsole/__tests__/contracts/tables.test.tsx` (**DELETED**) -- `docs/ASTRYX-MIGRATION-PLAN.md` — status bumped to "Phases 0-5 merged" -- `scripts/migrate_tables.py` (NEW) — one-shot migration script - -### Test results - -- **618 passing** (was 615: +12 new tables-astryx tests, -9 from deleted `tables.test.tsx`) -- 0 TS errors -- `npm run build` succeeds — 9 routes prerender cleanly - -### Migration plan reference - -`docs/ASTRYX-MIGRATION-PLAN.md` Phase 5 of 13. Next: Phase 6 — Tabs (TabList). diff --git a/dev.log b/dev.log deleted file mode 100644 index 91499ef..0000000 --- a/dev.log +++ /dev/null @@ -1,10 +0,0 @@ - -> amazon-ad-console@3.5.0 dev -> next dev - -▲ Next.js 16.2.10 (Turbopack) -- Local: http://localhost:3000 -✓ Ready in 1851ms - -○ Compiling /landing ... - GET /landing 200 in 7.7s (next.js: 7.3s, application-code: 365ms) diff --git a/docs/API.md b/docs/API.md deleted file mode 100644 index d8e8630..0000000 --- a/docs/API.md +++ /dev/null @@ -1,533 +0,0 @@ -# Engine API Reference - -All functions live in `src/engine/ad-console/` and can be imported via the public API barrel at `src/engine/ad-console/index.ts`. - -## Import Patterns - -```ts -// Full public API -import { calc, simulateDays, Campaign, Target } from '@/engine/ad-console'; - -// Core engine only (zero deps) -import { calc, addTarget, addNegative, normalizeCampaign } from '@/engine/ad-console/core/engine'; - -// Per-module (specific domain) -import { addTarget, addKeyword, addAsinTarget } from '@/engine/ad-console/core/engine/target'; -import { addNegative, harvestTerm } from '@/engine/ad-console/core/engine/negative'; -import { calc, formatRoas, acosClass } from '@/engine/ad-console/core/engine/metrics'; - -// Store hook -import { useAdConsoleStore } from '@/engine/ad-console/store'; -``` - ---- - -## Core Engine (`core/engine/`) - -The engine is split into per-domain modules under `core/engine/`. Import from the barrel: - -```ts -import { - normalizeCampaign, addTarget, addKeyword, addNegative, - calc, simulateDays, -} from '@/engine/ad-console/core/engine'; -``` - -### All Module Exports - -| Module | Exports | -|--------|---------| -| `campaign.ts` | `normalizeCampaign`, `toggleCampaignStatus`, `archiveCampaign`, `duplicateCampaign`, `updateCampaignSettings`, `savePlacements` | -| `target.ts` | `addTarget`, `addKeyword`, `addAutoTarget`, `addAsinTarget`, `addCategoryTarget`, `removeTarget`, `setTargetBid`, `adjustTargetBid`, `pauseTarget`, `setTargetStatus` | -| `adgroup.ts` | `addAdGroup`, `addProductAd`, `addAd`, `renameAdGroup`, `setAdGroupStatus`, `setAdGroupDefaultBid`, `removeAdGroup` | -| `negative.ts` | `isFilteredByNegative`, `addNegative`, `addNegativeKeyword`, `addNegativeAsin`, `addNegativeCategory`, `removeNegative`, `harvestTerm`, `getHarvestCandidates`, `getNegativeCandidates` | -| `budget.ts` | `addBudgetRule`, `removeBudgetRule`, `updateBudgetRule` | -| `portfolio.ts` | `createPortfolio`, `renamePortfolio`, `deletePortfolio`, `assignCampaignToPortfolio`, `campaignById`, `filteredCampaigns`, `portfolioNames` | -| `draft.ts` | `selectProduct`, `removeProduct`, `parseKeywords`, `validateStoreUrl` | -| `id.ts` | `generateId`, `resetIdCounter` | -| `metrics.ts` | `calc`, `totalMetrics`, `metricDefaults`, `formatMoney`, `formatWhole`, `formatBid`, `formatPercent`, `formatRoas`, `acosClass` | -| `responsive.ts` | `resolveBreakpoint`, `mobileMenuReducer`, `isTouchViewport` | -| `search-term-generator.ts` | `ExactMatchGenerator`, `PhraseMatchGenerator`, `BroadMatchGenerator`, `generateSearchTermsForTarget`, `registerGenerator` | -| `simulation.ts` | `simulateDays` | - -### ID Generation - -#### `generateId(prefix?: string): string` -Generates a unique ID with a timestamp-based component and incrementing counter. -- **prefix**: Default `'C'`. Used to namespace IDs (e.g., `'C'`, `'AG'`, `'T'`). -- **Returns**: `"{prefix}-{base36timestamp}-{counter}"` - ---- - -### Metrics - -#### `calc(metrics: Metrics): DerivedMetrics` -Computes derived KPIs from raw metrics. - -| Derived Metric | Formula | Range | -|---------------|---------|-------| -| `ctr` | clicks / impressions × 100 | 0–100% | -| `cpc` | spend / clicks | $0+ | -| `acos` | spend / sales × 100 | 0–100% | -| `roas` | sales / spend | 0+ | -| `cvr` | orders / clicks × 100 | 0–100% | - -#### `totalMetrics(campaigns: Campaign[]): Metrics` -Sums raw metrics (impressions, clicks, spend, sales, orders) across an array of campaigns. - -#### `metricDefaults(m: Partial<Metrics>): Metrics` -Fills missing metric fields with `0`. - ---- - -### Campaign Normalization - -#### `normalizeCampaign(c: Partial<Campaign>): Campaign` -Normalizes a partial campaign object into a fully-formed `Campaign` with: -- Validated `type` (SP/SB/SD) -- Generated `id` if missing -- Default ad group with generated ID -- Normalized targets, search terms, negatives, budget rules -- Default creative for SB/SD campaigns -- History entry logged - ---- - -### Campaign Operations - -#### `toggleCampaignStatus(c: Campaign): Campaign` -Cycles campaign status: `Enabled → Paused → Enabled`. Archived campaigns are unchanged. Logs the change. - -#### `archiveCampaign(c: Campaign): Campaign` -Sets campaign status to `'Archived'`. Logs the archive action. - -#### `duplicateCampaign(c: Campaign): Campaign` -Creates a deep copy of a campaign with: -- New unique ID -- New ad group IDs -- New target IDs -- Status set to `'Paused'` -- Name appended with `"(copy)"` - ---- - -### Target (Keyword) Operations - -#### `addTarget(opts: AddTargetOptions): { campaign: Campaign; target: Target }` -Adds a new target of any type to a campaign's ad group. - -**Options:** -- `campaign` — Target campaign -- `value` — Target text (keyword, ASIN, category path) -- `type` — `'Keyword'`, `'ASIN'`, `'Category'`, `'Auto - close match'`, `'Auto - loose match'`, `'Auto - substitutes'`, `'Auto - complements'`, or any audience type -- `match` — (optional, keyword only) `'Exact'` | `'Phrase'` | `'Broad'` -- `bid` — CPC bid in dollars -- `adGroupId` — (optional) Defaults to first ad group - -Throws `ValidationError` on empty value, non-finite bid, or unknown ad group. - -#### `addKeyword(campaign: Campaign, value: string, match: MatchType, bid: number, adGroupId?: string): { campaign: Campaign; target: Target }` -Convenience wrapper — same as `addTarget({ ..., type: 'Keyword' })`. - -#### `addAutoTarget(campaign: Campaign, autoType: AutoType, bid: number, adGroupId?: string): { campaign: Campaign; target: Target }` -Adds an auto-targeting target. `autoType` is one of `'close match'`, `'loose match'`, `'substitutes'`, `'complements'`. - -#### `addAsinTarget(campaign: Campaign, asin: string, bid: number, adGroupId?: string): { campaign: Campaign; target: Target }` -Adds an ASIN product target. - -#### `addCategoryTarget(campaign: Campaign, categoryPath: string, bid: number, adGroupId?: string): { campaign: Campaign; target: Target }` -Adds a category product target. - -#### `removeTarget(campaign: Campaign, targetId: string): Campaign` -Removes a target by ID. Logs the removal. - -#### `setTargetBid(campaign: Campaign, targetId: string, bid: number): Campaign` -Sets an exact bid on a target. Logs the change. - -#### `adjustTargetBid(campaign: Campaign, targetId: string, multiplier: number): Campaign` -Adjusts a target's bid by a multiplier (e.g., `1.2` = +20%, `0.8` = -20%). - -#### `pauseTarget(campaign: Campaign, targetId: string): Campaign` -Sets a target's status to `'Paused'`. - -#### `setTargetStatus(campaign: Campaign, targetId: string, status: CampaignStatus): Campaign` -Sets a target to any valid status (`'Enabled'` | `'Paused'` | `'Archived'`). - -### Ad Group Operations - -#### `addAdGroup(campaign: Campaign, name: string): Campaign` -Appends a new enabled ad group to the campaign. -- **name**: Ad group name (required, trimmed) -- Logs creation. Throws `ValidationError` on empty name. - -#### `renameAdGroup(campaign: Campaign, adGroupId: string, name: string): Campaign` -Renames an existing ad group. -- Throws `ValidationError` on unknown ID or empty name. - -#### `setAdGroupStatus(campaign: Campaign, adGroupId: string, status: CampaignStatus): Campaign` -Sets an ad group's status and cascades it to all targets in that group. -- Throws `ValidationError` on unknown ID. - -#### `setAdGroupDefaultBid(campaign: Campaign, adGroupId: string, defaultBid: number): Campaign` -Sets the default CPC bid for an ad group (clamped to ≥ $0.02). -- Throws `ValidationError` on unknown ID. - -#### `removeAdGroup(campaign: Campaign, adGroupId: string): Campaign` -Removes an ad group and all its associated targets. -- Throws `ValidationError` if it's the campaign's last ad group. - ---- - -### Budget Rule Operations - -#### `addBudgetRule(campaign: Campaign, name: string, type: string, increase: number, condition: string): { campaign: Campaign; rule: BudgetRule }` -Adds a Schedule or Performance budget rule to a campaign. -- **name**: Rule name (required, non-empty) -- **type**: `'Schedule'` or `'Performance'` -- **increase**: Budget multiplier (must be > 0) -- **condition**: Trigger condition (required, non-empty) -- Throws `ValidationError` on invalid type, non-positive increase, or empty name/condition. - -#### `removeBudgetRule(campaign: Campaign, ruleId: string): { campaign: Campaign; removed: boolean }` -Removes a budget rule by ID. Returns `removed: false` if the rule does not exist. - -#### `updateBudgetRule(campaign: Campaign, ruleId: string, updates: Partial<Pick<BudgetRule, 'name' | 'type' | 'increase' | 'condition'>>): { campaign: Campaign }` -Partially updates a budget rule's fields. All fields are validated before applying. -- Throws `ValidationError` on unknown rule ID or invalid field values. - -### Negatives & Harvesting - -#### `addNegative(opts: AddNegativeOptions): Campaign` -Adds a negative keyword or target at campaign or ad-group level. - -**Options:** -- `campaign` — Target campaign -- `value` — Negative text (keyword, ASIN, or category ID) -- `type` — `'Negative exact'` | `'Negative phrase'` | `'Negative ASIN'` | `'Negative category'` -- `adGroupId` — (optional) Omit for campaign-level, provide for ad-group-level -- `sourceSearchTermId` — (optional) Link back to the originating search term - -Deduplicates by value + type + level. Logs the addition. - -#### `addNegativeKeyword(campaign: Campaign, keyword: string, matchType: 'Negative exact' | 'Negative phrase', adGroupId?: string): Campaign` -Convenience wrapper — same as `addNegative({ ..., type: matchType })`. - -#### `addNegativeAsin(campaign: Campaign, asin: string, adGroupId?: string): Campaign` -Adds an ASIN-level negative (keyword negatives filter search terms; ASIN negatives filter product targeting). - -#### `addNegativeCategory(campaign: Campaign, categoryId: string, adGroupId?: string): Campaign` -Adds a category-level negative. - -#### `removeNegative(campaign: Campaign, negativeId: string): Campaign` -Removes a negative by ID. Logs the removal. - -#### `harvestTerm(campaign: Campaign, term: string, targetValue?: string): Campaign` -Finds a matching search term and: -1. Adds it as a new Exact keyword target (if not already present) -2. Links the search term to the new target via `targetId` and `targetValue` -3. Logs the harvest action - -#### `getHarvestCandidates(searchTerms: SearchTerm[], opts?: SearchTermFilterOptions): SearchTerm[]` -Returns search terms worth harvesting as keywords. Default thresholds: minSpend=0, minClicks=10, maxAcos=30, minOrders=1. Filters out already-converting terms. - -#### `getNegativeCandidates(searchTerms: SearchTerm[], opts?: SearchTermFilterOptions): SearchTerm[]` -Returns search terms worth negating. Default thresholds: minSpend=10, minClicks=5, maxAcos=50, minOrders=0. Returns high-spend, low-converting terms. - -#### `isFilteredByNegative(term: string, negatives: Negative[]): boolean` -Returns `true` if the term matches any negative exact or negative phrase. `Negative exact` requires an exact case-insensitive match; `Negative phrase` checks substring containment. ASIN/category negatives do not filter search terms. - ---- - -### Simulation - -#### `simulateDays(campaigns: Campaign[], days: number): Campaign[]` -Generates realistic performance data for enabled campaigns over N days. - -For each campaign: -1. Calculates daily CTR, CPC, conversion rate from existing metrics (with fallback defaults) -2. Generates impressions based on daily budget and bid competitiveness -3. Converts impressions → clicks → sales → orders with randomized variance -4. Distributes metrics across targets proportionally -5. Recomputes ad group metrics from target totals -6. Appends a history entry - -Returns new campaign objects (no mutation). - ---- - -### Settings - -#### `updateCampaignSettings(campaign: Campaign, updates: Partial<Pick<Campaign, 'dailyBudget' | 'defaultBid' | 'bidStrategy' | 'status' | 'creativeStatus' | 'creativeIssue'>>): Campaign` -Updates campaign-level settings (budget, bid, strategy, status, creative). Logs individual changes. - -#### `savePlacements(campaign: Campaign, placements: { top: number; product: number; rest: number }): Campaign` -Saves placement bid adjustment percentages. Logs changes. - -### Responsive / Mobile - -#### `resolveBreakpoint(width: number): 'mobile' | 'tablet' | 'desktop'` -Maps a pixel width to a breakpoint. -- `< 768px` → `'mobile'` -- `768–1100px` → `'tablet'` -- `> 1100px` → `'desktop'` - -#### `mobileMenuReducer(state?: MobileMenuState, action?: MobileMenuAction): MobileMenuState` -State machine for the mobile drawer navigation. -- **States**: `'closed'` | `'open'` | `'closing'` -- **Actions**: `INIT`, `TOGGLE`, `OPEN`, `CLOSE`, `ANIMATION_END` - -#### `isTouchViewport(hasTouch: boolean, width: number): boolean` -Returns `true` when the device has coarse pointer (touch) and width is ≤ 1100px. - - ---- - ---- - -### Portfolio Operations - -#### `createPortfolio(portfolios: string[], name: string): string[]` -Adds a new portfolio name to the list. No-op if name already exists. -- Throws `ValidationError` on empty name. - -#### `renamePortfolio(portfolios: string[], campaigns: Campaign[], oldName: string, newName: string): { portfolios: string[]; campaigns: Campaign[] }` -Renames a portfolio across both the portfolio list and all campaigns using it. -- Throws `ValidationError` on unknown old name or empty new name. - -#### `deletePortfolio(portfolios: string[], campaigns: Campaign[], name: string): { portfolios: string[]; campaigns: Campaign[] }` -Removes a portfolio name and unassigns campaigns using it (sets portfolio to `''`). -- Throws `ValidationError` if it's the last portfolio. - -#### `assignCampaignToPortfolio(campaigns: Campaign[], campaignId: string, portfolioName: string): Campaign[]` -Assigns a campaign to a portfolio by ID. -- Throws `ValidationError` on unknown campaign ID or empty portfolio name. - ---- - -### Wizard Helpers - -#### `selectProduct(draft: CampaignDraft, asin: string): CampaignDraft` -Adds an ASIN to the draft products list. No-op if already present. -- Throws `ValidationError` on empty ASIN. - -#### `removeProduct(draft: CampaignDraft, asin: string): CampaignDraft` -Removes an ASIN from the draft products list. -- Throws `ValidationError` if it's the last product. - -#### `parseKeywords(raw: string): string[]` -Parses a multi-line keyword string into trimmed, non-empty keyword values. -- Skips blank lines. Throws `ValidationError` if any keyword exceeds 200 characters. - -### Ad Group & Creative Operations - -#### `addAdGroup(campaign: Campaign, name: string): Campaign` -Appends a new enabled ad group. Throws on empty name. - -#### `renameAdGroup(campaign: Campaign, adGroupId: string, name: string): Campaign` -Renames an existing ad group. Throws on unknown ID or empty name. - -#### `setAdGroupStatus(campaign: Campaign, adGroupId: string, status: CampaignStatus): Campaign` -Sets status and cascades to all targets in that group. - -#### `setAdGroupDefaultBid(campaign: Campaign, adGroupId: string, defaultBid: number): Campaign` -Sets default CPC bid (clamped to ≥ $0.02). - -#### `removeAdGroup(campaign: Campaign, adGroupId: string): Campaign` -Removes an ad group and its targets. Throws if it's the last ad group. - -#### `addProductAd(campaign: Campaign, asin: string, adGroupId?: string): Campaign` -Adds a Sponsored Products product assignment. - -#### `addAd(campaign: Campaign, adFormat: AdFormat, creative?: Creative, adGroupId?: string): Campaign` -Adds a Sponsored Brands or Sponsored Display creative assignment. - -### Draft / Wizard Helpers - -#### `validateStoreUrl(url: string): ValidationResult` -Validates an Amazon Store URL format. Returns `{ valid: boolean; error?: string }`. - -### Helpers - -#### `campaignById(state: AdConsoleState, id: string): Campaign | undefined` -Finds a campaign by ID in the state. - -#### `filteredCampaigns(state: AdConsoleState): Campaign[]` -Returns campaigns filtered by the current filter state (type, status, portfolio, search text). - -#### `portfolioNames(campaigns: Campaign[]): string[]` -Returns unique portfolio names, sorted, with `'All'` prepended. - -#### `formatMoney(n: number): string` -Formats a number as `$X,XXX.XX`. - -#### `formatWhole(n: number): string` -Formats a number with locale-aware thousand separators. - -#### `formatBid(n: number): string` -Formats a bid as `$X.XX`. - -#### `formatPercent(n: number): string` -Formats a number as `XX.X%` (one decimal place). - -#### `formatRoas(n: number): string` -Formats ROAS as `X.XXx`. The `x` suffix indicates the ratio (e.g., `3.50x` = $3.50 return per $1 spend). - -#### `acosClass(acos: number): string` -Returns `'good'` (≤20%), `'warn'` (20–49%), or `'bad'` (≥50%). - ---- - -## Feature Engines - -### Drills Engine (`features/drills/engine.ts`) - -| Function | Signature | Description | -|----------|-----------|-------------| -| `getDrills` | `(): DrillDefinition[]` | Returns all 5 drill definitions | -| `getDrill` | `(id: DrillId): DrillDefinition \| undefined` | Get a drill by ID | -| `createSession` | `(): DrillSession` | Create an empty drill session | -| `startDrill` | `(id: DrillId): DrillSession` | Start a drill session | -| `isCorrectAction` | `(session, drill, action): boolean` | Check if an action matches the current step | -| `advanceStep` | `(session, drill): DrillSession` | Move to the next step | -| `recordMistake` | `(session): DrillSession` | Increment mistake counter | -| `recordSkip` | `(session, drill): DrillSession` | Skip a step (if allowed) | -| `calculateScore` | `(session, totalSteps): number` | Compute 0–100 score | - -### Profiles Engine (`features/profiles/engine.ts`) - -| Function | Signature | Description | -|----------|-----------|-------------| -| `createProfile` | `(name: string): TraineeProfile` | Create a new profile | -| `switchProfile` | `(profiles, id): TraineeProfile[]` | Update lastActiveAt on switch | -| `renameProfile` | `(profiles, id, name): TraineeProfile[]` | Rename a profile | -| `deleteProfile` | `(profiles, id): TraineeProfile[]` | Remove a profile | -| `defaultProfile` | `(): TraineeProfile` | Create the default "Trainee" profile | - -### Trainer Engine (`features/trainer/engine.ts`) - -| Function | Signature | Description | -|----------|-----------|-------------| -| `addNote` | `(text: string): TrainerNote` | Create a timestamped note | -| `calculateCertScore` | `(items: CertificationItem[]): number` | Compute 0–100 cert score | -| `calculateGrade` | `(type: string): { tone }` | Auto-grade an action type | - -### Bulk Engine (`features/bulk/engine.ts`) - -| Function | Signature | Description | -|----------|-----------|-------------| -| `parseBulkCsv` | `(csv: string): BulkRow[]` | Parse CSV text into row objects | -| `validateBulkRows` | `(rows: BulkRow[]): BulkValidationError[]` | Validate rows against schema | -| `generateTemplate` | `(): string` | Generate a blank CSV template | - -### Reports Engine (`features/reports/engine.ts`) - -| Function | Signature | Description | -|----------|-----------|-------------| -| `createReportRequest` | `(type: ReportType): ReportRequest` | Create a pending report request | -| `generateReport` | `(type: ReportType): Report` | Generate a report with mock data | -| `reportToCsv` | `(report: Report): string` | Convert report to CSV string | - -### Missions Engine (`features/missions/engine.ts`) - -| Function | Signature | Description | -|----------|-----------|-------------| -| `getMissions` | `(): Mission[]` | Returns all 3 mission definitions | -| `getMission` | `(id: string): Mission \| undefined` | Get a mission by ID | -| `createMissionSession` | `(): MissionSession` | Create an empty mission session | -| `startMission` | `(id: string): MissionSession` | Start a mission (score = 100) | -| `useHint` | `(session): MissionSession` | Use a hint (-10 score) | -| `completeStep` | `(session, totalSteps): MissionSession` | Advance to next step | - -### Integrity Engine (`features/integrity/engine.ts`) - -| Function | Signature | Description | -|----------|-----------|-------------| -| `runIntegrityCheck` | `(campaigns: Campaign[]): IntegrityReport` | Run all data-quality checks | - -Checks performed: -- Archived campaigns with active children -- Duplicate target IDs -- Orphaned search terms (no target link) -- SD campaigns with search term rows -- SB campaigns with rejected creative -- Low-inventory products - -Score: `100 - (errors × 15) - (warnings × 5)`, passes at ≥70. - ---- - -## Store API (`store.ts`) - -### Core Actions - -| Action | Parameters | Description | -|--------|-----------|-------------| -| `selectCampaign` | `(id: string \| null)` | Select a campaign for detail view | -| `setView` | `(view)` | Navigate to a view | -| `setTab` | `(tab: string)` | Switch tab within campaign detail | -| `setFilter` | `(filter: Partial<FilterState>)` | Update campaign list filters | -| `toggleCampaignStatus` | `(id: string)` | Toggle enabled/paused | -| `archiveCampaign` | `(id: string)` | Archive a campaign | -| `duplicateCampaign` | `(id: string)` | Clone a campaign | -| `addKeyword` | `(campaignId, value, match, bid, adGroupId?)` | Add a keyword target to an ad group | -| `addAdGroup` | `(campaignId, name)` | Add an ad group | -| `renameAdGroup` | `(campaignId, adGroupId, name)` | Rename an ad group | -| `setAdGroupStatus` | `(campaignId, adGroupId, status)` | Set ad group status (cascades to targets) | -| `setAdGroupDefaultBid` | `(campaignId, adGroupId, bid)` | Set ad group default bid | -| `removeAdGroup` | `(campaignId, adGroupId)` | Remove an ad group | -| `createPortfolio` | `(name)` | Create a new portfolio | -| `renamePortfolio` | `(oldName, newName)` | Rename a portfolio | -| `deletePortfolio` | `(name)` | Delete a portfolio | -| `assignCampaignToPortfolio` | `(campaignId, portfolioName)` | Assign campaign to portfolio | -| `selectProduct` | `(asin)` | Add product to draft | -| `removeProduct` | `(asin)` | Remove product from draft | -| `removeTarget` | `(campaignId, targetId)` | Remove a target | -| `setTargetBid` | `(campaignId, targetId, bid)` | Set exact bid | -| `adjustTargetBid` | `(campaignId, targetId, multiplier)` | Adjust bid by multiplier | -| `pauseTarget` | `(campaignId, targetId)` | Pause a target | -| `addNegative` | `(campaignId, term, type?)` | Add negative keyword | -| `harvestTerm` | `(campaignId, term)` | Harvest a search term | -| `runSimulation` | `(days?: number)` | Run N-day simulation (default 7) | -| `updateCampaignSettings` | `(id, updates)` | Update campaign settings | -| `savePlacements` | `(id, placements)` | Save placement adjustments | -| `updateDraft` | `(field, value)` | Update campaign creation draft | -| `setWizardStep` | `(step: number)` | Set wizard step | -| `resetDraft` | `()` | Reset creation wizard | -| `launchCampaign` | `()` | Create campaign from draft | -| `toggleAddKeywordForm` | `()` | Toggle keyword form visibility | -| `toggleMobileMenu` | `()` | Toggle mobile drawer open/close | -| `closeMobileMenu` | `()` | Start mobile drawer closing animation | -| `mobileMenuAnimationEnd` | `()` | Complete closing transition to closed | -| `addBudgetRule` | `(campaignId, name, type, increase, condition)` | Add budget rule (Schedule/Performance) | -| `removeBudgetRule` | `(campaignId, ruleId)` | Remove a budget rule | -| `updateBudgetRule` | `(campaignId, ruleId, updates)` | Partially update a budget rule | -| `resetAll` | `()` | Reset to default campaigns | -| `exportState` | `(): string` | Export state as JSON string (rejects empty/null) | -| `importState` | `(json: string): boolean` | Import state from JSON string (rejects empty/null) | - -### New Actions (from refactoring) - -In addition to the existing actions above, the store exposes: - -| Action | Parameters | Description | -|--------|-----------|-------------| -| `addAutoTarget` | `(campaignId, autoType, bid, adGroupId?)` | Add auto-close/loose/substitutes/complements target | -| `addAsinTarget` | `(campaignId, asin, bid, adGroupId?)` | Add ASIN product target | -| `addCategoryTarget` | `(campaignId, categoryPath, bid, adGroupId?)` | Add category target | -| `addProductAd` | `(campaignId, asin, adGroupId?)` | Add product ad assignment | -| `addAd` | `(campaignId, adFormat, creative?, adGroupId?)` | Add creative assignment | -| `addNegativeKeyword` | `(campaignId, keyword, matchType, adGroupId?)` | Add negative keyword | -| `addNegativeAsin` | `(campaignId, asin, adGroupId?)` | Add ASIN negative | -| `addNegativeCategory` | `(campaignId, categoryId, adGroupId?)` | Add category negative | -| `removeNegative` | `(campaignId, negativeId)` | Remove a negative | -| `setTargetStatus` | `(campaignId, targetId, status)` | Set target to any status | - -### Derived Getters - -| Getter | Returns | Description | -|--------|---------|-------------| -| `filtered()` | `Campaign[]` | Filtered campaign list | -| `selectedCampaign()` | `Campaign \| undefined` | Currently selected campaign | -| `portfolioOptions()` | `string[]` | Portfolio filter options | -| `totalMetricsCalc()` | `Metrics` | Aggregate metrics (enabled campaigns) | -| `derivedMetrics(m)` | `DerivedMetrics` | Compute KPIs from raw metrics | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index cdeec41..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,256 +0,0 @@ -# Architecture - -## Design Philosophy - -The Amazon Ad Console follows **SOLID principles** with strict separation between business logic (engine) and presentation (React UI). The engine layer has **zero framework dependencies** — it is pure TypeScript that can run in any JavaScript environment. - -## Layer Architecture - -``` -┌─────────────────────────────────────────────┐ -│ Next.js App Router (pages/layout) │ -├─────────────────────────────────────────────┤ -│ React Components (UI layer) │ -│ Components/AdConsole/* │ -├─────────────────────────────────────────────┤ -│ Zustand Store (state management) │ -│ store.ts — composed root store │ -│ 8 independent slices │ -├─────────────────────────────────────────────┤ -│ API Routes (server-side) │ -│ /api/auth/* — authentication │ -│ /api/campaigns/* — campaign CRUD │ -│ /api/sync — bulk data sync │ -├─────────────────────────────────────────────┤ -│ Database Layer (Prisma + Postgres) │ -│ User, Campaign, Simulation models │ -├─────────────────────────────────────────────┤ -│ Feature Engines (per-module business logic)│ -│ features/drills/engine.ts │ -│ features/profiles/engine.ts │ -│ features/trainer/engine.ts │ -│ features/bulk/engine.ts │ -│ features/reports/engine.ts │ -│ features/missions/engine.ts │ -│ features/integrity/engine.ts │ -├─────────────────────────────────────────────┤ -│ Core Engine (zero dependencies) │ -│ core/engine/ — per-domain modules: │ -│ campaign.ts — normalization, lifecycle │ -│ target.ts — keyword, ASIN, category, auto│ -│ adgroup.ts — ad group CRUD │ -│ negative.ts — filters, harvesting │ -│ budget.ts — budget rule CRUD │ -│ portfolio.ts — portfolio operations │ -│ draft.ts — campaign wizard helpers │ -│ id.ts — ID generation │ -│ metrics.ts — calc, format helpers │ -│ search-term-generator.ts — Strategy pat. │ -│ responsive.ts — breakpoints, mobile menu │ -│ core/simulation.ts — 7-day perf simulation │ -│ core/types.ts — all domain interfaces │ -│ core/scenarios.ts — training scenario defs │ -└─────────────────────────────────────────────┘ -``` - -## SOLID Principles Applied - -### Single Responsibility -Each feature module owns exactly one concern: -- `drills/` — navigation coaching and scoring -- `profiles/` — multi-user trainee management -- `trainer/` — certification checklist and action grading -- `bulk/` — CSV parsing and validation -- `reports/` — report generation and CSV export -- `missions/` — scenario-based challenges -- `integrity/` — data quality auditing - -### Open/Closed -The store is composed via `StateCreator` slices. Adding a new feature means creating a new `features/<name>/` directory with `types.ts`, `engine.ts`, `store.ts` — no existing files need modification. - -### Liskov Substitution -All store slices implement independent `StateCreator<T>` interfaces. The root store combines them via intersection (`CoreSlice & DrillsSlice & ...`) — any slice can be swapped or mocked independently. - -### Interface Segregation -Each feature exports its own typed slice interface (`DrillsSlice`, `ProfilesSlice`, etc.). Components import only the slice types they need. The core `AppStore` type is a composition of all slices. - -### Dependency Inversion -- Components depend on the `useAdConsoleStore` hook (abstraction), not on concrete state shape -- Feature engines depend only on `core/types.ts` interfaces (abstractions) - -## Entity Hierarchy - -The console models the Amazon Advertising API entity structure: - -``` -Account (system level) -└── Portfolio — free-text grouping of campaigns -└── Campaign (SP / SB / SD) - ├── AdGroup — bid + status container - │ └── Target — keyword, ASIN, category, auto, audience - ├── ProductAd — SP/SB product assignment - ├── Ad — SB/SD creative assignment - ├── SearchTerm — shopper query report (linked to Target) - ├── Negative — campaign-level or ad-group-level filter - └── BudgetRule — Schedule or Performance automation -``` - -## Data Flow - -``` -User Action → Component → Store Slice → Engine Function → New State → Component Re-render -``` - -### State Management -- **Zustand Store**: Single source of truth for UI state -- **8 Core Slices**: Target, AdGroup, Negative, Budget, Portfolio, Draft, Core, Query -- **7 Feature Slices**: Drills, Profiles, Trainer, Bulk, Reports, Missions, Integrity -- **Persistence**: LocalStorage via Zustand persist middleware -- **Cloud Sync**: Optional database persistence via API routes -- **Persistence**: LocalStorage via Zustand persist middleware -- **Cloud Sync**: Optional database persistence via API routes - -### Server-Side Data Flow -``` -Component → API Route → Prisma Client (Neon adapter) → Postgres Database - ↓ -Component ← API Response ← Prisma Query Result -``` - -## Authentication & Authorization - -### NextAuth Configuration -- **Provider**: Credentials (email/password) -- **Session Strategy**: JWT -- **Password Hashing**: bcryptjs -- **Database**: Postgres via Prisma (`@prisma/adapter-neon`) - -### API Route Protection -All `/api/*` routes check for valid session: -```typescript -const session = await auth(); -if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); -} -``` - -### Data Isolation -Each user's campaigns are isolated by `userId`: -```typescript -const campaigns = await prisma.campaign.findMany({ - where: { userId: session.user.id }, -}); -``` - -## Database Schema - -### User Model -```prisma -model User { - id String @id @default(cuid()) - email String @unique - name String? - passwordHash String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - campaigns Campaign[] - simulations Simulation[] -} -``` - -### Campaign Model -```prisma -model Campaign { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - campaignId String // Original campaign ID from the engine - type String // SP, SB, SD - name String - // ... all campaign fields - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([userId, campaignId]) -} -``` - -## Responsive Design - -### Breakpoint System -- **Mobile**: < 768px — Single column, hamburger menu, touch-optimized -- **Tablet**: 768-1100px — Condensed sidebar, adapted spacing -- **Desktop**: > 1100px — Full layout with sidebar - -### Mobile-First Approach -- All components designed for mobile first -- Progressive enhancement for larger screens -- Touch targets minimum 48px -- Safe area padding for iPhone notch - -## Testing Strategy - -### Unit Tests -- **Location**: `src/engine/ad-console/core/__tests__/` + `tests/engine.test.ts` -- **Framework**: Vitest -- **Coverage**: 446 tests across 27 test files -- **Principle**: TDD — write failing test first -- **Strategy**: Red-Green-Refactor cycle with SOLID compliance gates - -### Integration Tests -- **Location**: `src/components/AdConsole/__tests__/` -- **Framework**: Vitest + React Testing Library -- **Coverage**: Component behavior tests - -### E2E Tests -- **Location**: `e2e/` -- **Framework**: Playwright -- **Coverage**: Critical user flows - -## Performance Considerations - -### Client-Side -- Zustand selectors for minimal re-renders -- React.memo for expensive components -- Virtual scrolling for large lists (planned) - -### Server-Side -- Prisma connection pooling -- JWT sessions (no database lookup per request) -- Static generation for landing pages - -### Database -- Postgres (production via Vercel Postgres, local via Docker or pg) -- Connection pooling via Prisma -- Indexes on frequently queried fields - -## Security - -### Authentication -- Password hashing with bcrypt (10 rounds) -- JWT tokens with secure HTTP-only cookies -- Session expiration via NextAuth - -### Authorization -- User data isolation via userId foreign key -- API route protection via session checks -- No cross-user data access - -### Input Validation -- Server-side validation on all API routes -- Client-side validation for UX -- SQL injection prevention via Prisma ORM - -## Deployment - -### Environment Variables -```env -DATABASE_URL="postgresql://..." -AUTH_SECRET="your-secret-here" -``` - -### Production Considerations -- Rate limiting on API routes -- CSRF protection -- Logging and monitoring -- Connection pooling for Postgres diff --git a/docs/ASTRYX-MIGRATION-PLAN.md b/docs/ASTRYX-MIGRATION-PLAN.md deleted file mode 100644 index 31d6fc6..0000000 --- a/docs/ASTRYX-MIGRATION-PLAN.md +++ /dev/null @@ -1,392 +0,0 @@ -# Astryx Migration Plan - -**Status:** Phases 0-5 merged -**Date:** 2026-07-24 -**Owner:** Amazon Ad Console -**Approach:** TDD-first, SOLID, theme-preserving, incremental - ---- - -## Why this plan exists - -The first Astryx migration (PRs #32–#36) shipped 5 PRs across 6 weeks, broke 14 unit -tests, regressed the global visual theme in three places, and was force-reverted -from `main` on 2026-07-24. This plan replaces that approach with a tighter, test-first -strategy where every commit must: - -1. **Pass `npm test`** with the same number of tests as before the change. -2. **Preserve the existing Amazon-themed design tokens** (no visual regression). -3. **Migrate one component per PR** so a regression points to a single diff. -4. **Have a failing test first** for the new behaviour (TDD). -5. **Keep `main` deployable** at every commit. - ---- - -## Audit summary (2026-07-24, pre-migration) - -### Codebase scope - -- **53** TS/TSX files under `src/components/AdConsole/` -- **2,647** lines of CSS in `src/app/globals.css` (Amazon platform palette) -- **511** passing tests across 33 files -- **0** direct Astryx component imports today (just CSS reset/theme is loaded) - -### Custom CSS classes used by the UI (the migration surface) - -| Class family | Where | Astryx target | -|---|---|---| -| `.app-navbar` / `.nav-section` / `.nav-brand` | layout/Topbar | `TopNav` + `NavItem` | -| `.app-sidebar` / `.sidebar-item` / `.sidebar-group-title` | layout/Sidebar + mobile/MobileNav | `SideNav` + `Section` + `NavItem` | -| `.card` / `.card.pad` / `.card-title` | every page | `Card` | -| `.btn` / `.btn.primary` / `.btn.blue` / `.btn.danger` / `.btn.warn` | every page | `Button variant=…` | -| `.tabs` / `.tab` | CampaignManager, CampaignDetail | `TabList` + `Item` | -| `.toolbar` | every page that has filters | `Toolbar` | -| `.metric-card` / `.kpi-tile` | Dashboard, CampaignManager, PortfolioOverview | `Card` + `MetricCard` (keep ours) | -| `.table-wrap` + raw `<table>` | every tab | `Table` | -| `.choice-grid` / `.choice-card` | Step1AdType (wizard) | `Grid` + `SelectableCard` | -| `.empty` / `.empty-state` | every tab when no data | `EmptyState` | -| `.input` / `.select` / `.textarea` | every form | `TextInput` / `Selector` / `TextArea` | -| `.badge` (in `.btn.solid .status`/inline pills) | tab tables, headers | `StatusDot` (when boolean) / `Badge` (when labelled) | -| `.banner` / `.alert-inline` | BulkOpsPage errors, login/register | `Banner status=…` | -| `.field` + `<label>` + `<input>` | every form | `Field` + `FieldStatus` + `TextInput` | -| `.sim-overlay` | CampaignManager sim loading | `Dialog` / `Layer` + `Spinner` | -| `.dot` (loading dots) | sim overlay | `Spinner` | -| `.tag` (chips) | Step2Basics, Step6ReviewLaunch | `Token` | -| raw `<h1>` `<h2>` `<h3>` | every page | `Heading as=h1/h2/h3` | -| raw `<p>` / muted text | everywhere | `Text type=…` | - -### Astryx components available (catalog checked at 0.1.8) - -- **Layout:** `AppShell`, `Layout`, `LayoutContent`, `LayoutNav`, `LayoutTopBar`, `Grid`, `Stack`, `HStack`, `VStack`, `Section`, `Center`, `AspectRatio`, `Layer` -- **Nav:** `TopNav`, `SideNav`, `Section`, `NavItem`, `NavIcon`, `NavMenu`, `MobileNav`, `Breadcrumbs`, `TabList`, `Toolbar`, `Pagination` -- **Surfaces:** `Card`, `ClickableCard`, `SelectableCard`, `EmptyState`, `Outline` -- **Buttons:** `Button`, `IconButton`, `ToggleButton`, `ButtonGroup` -- **Inputs:** `TextInput`, `TextArea`, `NumberInput`, `DateInput`, `DateRangeInput`, `DateTimeInput`, `TimeInput`, `CheckboxInput`, `CheckboxList`, `RadioList`, `Selector`, `MultiSelector`, `Typeahead`, `FileInput`, `Slider`, `Switch` -- **Form helpers:** `Field`, `FieldStatus`, `FormLayout` -- **Display:** `Text`, `Heading`, `Code`, `CodeBlock`, `Kbd`, `Markdown`, `Blockquote`, `Citation`, `MetadataList`, `Thumbnail`, `Avatar`, `AvatarGroup`, `Timestamp`, `StatusDot`, `Token`, `Tokenizer`, `VisuallyHidden` -- **Data:** `Table`, `PowerSearch`, `OverflowList`, `List`, `Item` -- **Feedback:** `Banner`, `AlertDialog`, `Dialog`, `Toast`, `Tooltip`, `Popover`, `HoverCard`, `Skeleton`, `Spinner`, `ProgressBar` -- **Menu:** `CommandPalette`, `ContextMenu`, `MoreMenu`, `DropdownMenu` - -### Theme and visual contract - -- The Amazon brand palette (`#ff9900` accent, `#131921` top nav, `#eaeded` page bg, - `#007185` info) is encoded in `:root` CSS variables in `globals.css`. **These must - not change.** Astryx's `theme-neutral` uses different tokens; we must configure - Astryx to inherit our `--surface-*`, `--ink-*`, `--border-*`, `--accent-*`, - `--success`, `--warning`, `--danger`, `--info` tokens via a theme bridge - (Phase 1). - -### The 9 contracts the previous migration broke (do not break again) - -1. `ResizeObserver` mock must be a class — Astryx `Text` truncation uses it. -2. `Selector` and `TextInput` do not support `type=number`/`type=date` natively - (Astryx has `NumberInput` / `DateInput` for those). -3. `as` prop on `Text` is required when rendering headings; can't be cast via JSX. -4. `xstyle` requires a `StyleX.create()` binding; plain `style` prop works. -5. `Button icon={SomeIcon}` requires `ReactNode`, not a function. -6. `Badge label="Neg"` — `Neg` is fine, no enum restriction at runtime. -7. `Target.metrics` does not exist; `Target` has flat fields - (impressions/clicks/spend/sales/orders). -8. `CampaignDraft.brandKeywords` does not exist; it's `broadKeywords`. -9. `Stack vAlign=baseline` → use `vAlign=center`; `wrap=boolean` → `wrap="wrap"`. - ---- - -## Phase plan - -Each phase is a single PR with its own tests. Every phase has a **"must pass" gate** -that blocks the next phase from starting. - -### Phase 0 — Foundation (TDD setup, no UI change) - -**Goal:** Lock down the existing UI contracts in tests so we can migrate against -them. No Astryx imports yet. - -**Files:** -- `vitest.setup.ts` — proper `ResizeObserver` class mock -- `__tests__/contracts/sidebar.test.tsx` — active item reflects view + selected tab -- `__tests__/contracts/topbar.test.tsx` — active section follows view -- `__tests__/contracts/buttons.test.tsx` — every `.btn` variant renders and is clickable -- `__tests__/contracts/cards.test.tsx` — `.card` has correct padding -- `__tests__/contracts/tabs.test.tsx` — `.tab.active` matches store state -- `__tests__/contracts/tables.test.tsx` — `.table-wrap` wraps in scroll container -- `__tests__/contracts/forms.test.tsx` — label-wired inputs render -- `__tests__/contracts/a11y.test.tsx` — main landmark, skip link, nav buttons - -**Gate:** `npm test` shows 511+ tests passing. CI green. - -### Phase 1 — Theme bridge (theme, no component change) - -**Goal:** Configure Astryx to consume our Amazon CSS variables without changing -either side's tokens. - -**Files:** -- `src/app/astryx-theme.css` (NEW) — maps Astryx design tokens to our `--surface-*`, - `--ink-*`, etc. -- `src/app/providers.tsx` — wrap with `AstryxProvider` from `@astryxdesign/core` -- `globals.css` — add the theme bridge import; **no token changes** -- `__tests__/astryx-theme.test.ts` (NEW) — asserts our CSS variables drive Astryx - components - -**Gate:** `npm test` + visual check. The app looks identical. Astryx docs import -without changing visuals. - -### Phase 2 — Buttons (the most-reused component) - -**Goal:** Migrate every `<button class="btn …">` to `<Button variant="…">` while -keeping all existing behaviour, sizes, and click handlers. - -**Mapping:** -| Old | New | -|---|---| -| `<button className="btn primary">` | `<Button variant="primary" />` | -| `<button className="btn blue">` | `<Button variant="info" />` | -| `<button className="btn danger">` | `<Button variant="danger" />` | -| `<button className="btn warn">` | `<Button variant="warning" />` | -| `<button className="btn">` | `<Button variant="secondary" />` | -| `<button className="btn ghost">` | `<Button variant="tertiary" />` | -| `<button className="btn small primary">` | `<Button variant="primary" size="sm" />` | - -**Files (one per touched page, 1 PR):** -- `__tests__/Button.contract.test.tsx` (NEW) — covers all variants/sizes/states -- `details/ManagerCampaignsTab.tsx` -- `details/ManagerAdGroupsTab.tsx` -- `details/ManagerTargetsTab.tsx` -- `details/ManagerSearchTermsTab.tsx` -- `details/ManagerNegativesTab.tsx` -- `details/OverviewTab.tsx`, `AdGroupsTab.tsx`, `TargetsTab.tsx`, `NegativesTab.tsx`, - `BudgetRulesTab.tsx`, `SearchTermsTab.tsx`, `PlacementsTab.tsx`, `HistoryTab.tsx` -- `features/bulk/BulkOpsPage.tsx`, `features/reports/ReportsPage.tsx`, - `features/drills/DrillsPage.tsx`, `features/missions/MissionsPage.tsx`, - `features/integrity/IntegrityPage.tsx`, `features/trainer/TrainerPage.tsx` -- `wizard/Step1AdType.tsx` (NEW design with `SelectableCard`) -- `wizard/Step2Basics.tsx` + all Step3/4/5 (SP/SB/SD) -- `wizard/Step6ReviewLaunch.tsx` -- `wizard/CreateCampaignWizard.tsx` (Next/Back) -- `Dashboard.tsx`, `CampaignManager.tsx`, `CampaignDetail.tsx`, - `PortfolioOverview.tsx` -- `layout/Sidebar.tsx`, `layout/Topbar.tsx` (sidebar/topbar items) -- `mobile/MobileNav.tsx` -- `auth/login/page.tsx`, `auth/register/page.tsx`, `auth/account/page.tsx` -- `landing/page.tsx` - -**Gate:** All buttons look identical, all click handlers fire, all sizes/disabled -states preserved. 511+ tests passing. - -### Phase 3 — Cards and Surfaces - -**Goal:** Migrate `<div className="card pad">` to `<Card>` and metric cards to -`<Card variant="elevated">`. - -**Files:** -- `__tests__/Card.contract.test.tsx` (NEW) -- All files touched in Phase 2 that have `.card` containers - -**Gate:** Visual identical. Card padding/radius/shadow unchanged. 511+ tests. - -### Phase 4 — Form fields - -**Goal:** Migrate raw `<input>`/`<select>`/`<textarea>` to Astryx field components -while preserving label/htmlFor wiring (the audit pinned this). - -**Mapping:** -| Old | New | -|---|---| -| `<input className="input" type="text">` | `<TextInput />` | -| `<input className="input" type="number">` | `<NumberInput />` | -| `<select className="select">` | `<Selector />` | -| `<textarea className="textarea">` | `<TextArea />` | -| `<div className="field"><label/><input/></div>` | `<Field label="…">…</Field>` | - -**Constraint:** `label htmlFor={id}` wiring must remain. We test the wiring in -`a11y.test.tsx` and the existing `tabs.test.tsx` OverviewTab input check. - -**Gate:** 511+ tests. a11y.test.tsx green. - -### Phase 5 — Tables - -**Goal:** Migrate raw `<table>` inside `.table-wrap` to `<Table>` with sticky -header. - -**Files:** -- `__tests__/Table.contract.test.tsx` (NEW) — sticky header, hover row, no overflow -- All manager tab tables - -**Gate:** Tabular numerals preserved. Sort/filter not regressed. 511+ tests. - -### Phase 6 — Tabs (TabList) - -**Goal:** Migrate `.tabs`/`.tab` to `<TabList>`. - -**Files:** -- `__tests__/TabList.contract.test.tsx` (NEW) -- `CampaignManager.tsx`, `CampaignDetail.tsx`, `BulkOpsPage.tsx`, `ReportsPage.tsx` - -**Gate:** 511+ tests. - -### Phase 7 — Toolbar (filter bars) - -**Goal:** Migrate `.toolbar` to `<Toolbar>`. - -**Files:** -- `__tests__/Toolbar.contract.test.tsx` (NEW) -- `CampaignManager.tsx`, `BulkOpsPage.tsx`, `ReportsPage.tsx` - -**Gate:** 511+ tests. - -### Phase 8 — Wizard Step 1 (SelectableCard grid) - -**Goal:** 3 ad-type cards in a `Grid` with `<SelectableCard>` and selection -buttons below (this matches the user's `/aesthetic` request). - -**Files:** -- `__tests__/Step1AdType.test.tsx` (NEW) — covers selection state + button click -- `wizard/Step1AdType.tsx` - -**Gate:** Selecting a card via button works; visual identical to the existing -`choice-grid` design. 511+ tests. - -### Phase 9 — Sidebar (the rebuild) - -**Goal:** Replace the custom `.app-sidebar` markup with `<SideNav>` + `<Section>` -+ `<NavItem>`. Keep `resolveSidebarClick`, `isSidebarItemActive`, and -`sidebarSectionForView` as the only place that knows about routes. - -**Files:** -- `__tests__/Sidebar.test.tsx` (NEW) — covers all 5 sections, active item, - action handlers, footer buttons -- `layout/Sidebar.tsx` -- `mobile/MobileNav.tsx` (uses same SideNav, in a Dialog/Drawer) - -**Gate:** Visual identical, all routes reachable, all 4 nav groups render, footer -buttons (Run 7-day sim, Reset sandbox) work. 511+ tests. - -### Phase 10 — Topbar (TopNav) - -**Goal:** Replace `.app-navbar` markup with `<TopNav>` + `<NavItem>`. - -**Files:** -- `__tests__/Topbar.test.tsx` (NEW) -- `layout/Topbar.tsx` - -**Gate:** Visual identical, all 4 sections, SyncButton + UserMenu still mounted. -511+ tests. - -### Phase 11 — AppShell (the layout host) - -**Goal:** Wrap the whole app in `<AppShell>` with `TopNav` + `SideNav` slots. - -**Files:** -- `__tests__/AppShell.test.tsx` (NEW) — slots render, mobile breakpoint collapses -- `src/app/AdConsole/page.tsx` or `AdConsole.tsx` root - -**Gate:** 511+ tests. Mobile/tablet/desktop breakpoints work. - -### Phase 12 — Empty states, banners, spinners (the small stuff) - -**Goal:** Migrate remaining primitives. - -**Files:** -- `__tests__/Banner.contract.test.tsx` -- `__tests__/EmptyState.contract.test.tsx` -- All `.empty` containers → `<EmptyState>` -- All banner/inline alerts → `<Banner status="error|warning|info|success">` -- `.sim-overlay` → `<Dialog>` + `<Spinner>` - -**Gate:** 511+ tests. - -### Phase 13 — Cleanup (only after all phases merged) - -- Remove legacy `.btn` / `.card` / `.tabs` / `.toolbar` / `.table-wrap` / - `.choice-grid` / `.empty` CSS classes from `globals.css` (and the variables - they referenced). Anything still using them gets a one-line fix. -- Drop the `feat: complete Astryx Badge migration` work-around (`Badge` was used - as a temporary substitute for `StatusDot`/`Token` in the previous migration). -- Add a `docs/MIGRATION-NOTES.md` for the team. - -**Gate:** No dead CSS rules. `npm test` + `npm run build` green. - ---- - -## TDD conventions - -- **Test first, code second.** Every migration PR must include the contract test - in the same commit that flips the import. No "fix tests later". -- **One test per behaviour, not per component.** A `Button` change is one test file - that enumerates variants, sizes, states. -- **DOM-level assertions only.** `screen.getByRole`, `screen.getByText`, - `data-testid` as a last resort. No snapshot tests of CSS. -- **Visual diff is human.** The CI does not check pixel diffs. PR descriptions - must include a screenshot of the affected page. - ---- - -## SOLID enforcement - -The migration touches the Liskov and Interface Segregation boundaries hardest. -The rules: - -- **S** — Each new component file owns one render concern. Wrapping `Button` - inside a `Card` does not mean putting the click handler on the `Card`. -- **O** — Adding a new Astryx variant is a config change, not a refactor. - No `if (variant === 'foo')` in the wrapper. -- **L** — The new `Sidebar` must accept the same `view` + `setView` props as the - old one. `isSidebarItemActive` and `resolveSidebarClick` stay the only - route-aware helpers. -- **I** — Don't pass the whole store to a primitive. `TextInput` gets - `value` + `onChange` + `label`, never the whole store. -- **D** — Own the data shape in one place: `useCampaignManager`, - `useCampaignDetail`. View components depend on the hook, not the store. - ---- - -## Rollback - -Each phase is a single PR on a single branch (`phase/2-buttons`, `phase/3-cards`). -If a phase regresses, revert the PR and `main` returns to the last green state. -The `main` deploy hook is configured to refuse merges with `vitest` failures. - ---- - -## Phasing rationale - -1. **Phase 0** is the safety net — it pins the current behaviour so we can - migrate against it. -2. **Phase 1** is the theme bridge — without it, Astryx components would look - off-theme. -3. **Phases 2–7** are the most-used primitives, in order of how often they appear. -4. **Phases 8–10** are the structural rebuilds (wizard step 1, sidebar, topbar). -5. **Phase 11** is the layout host. -6. **Phase 12** is the small stuff. -7. **Phase 13** is the cleanup. - -This ordering means **at any point after Phase 4**, the app is at least 60% -migrated and CI is still green if we stop. - ---- - -## Open questions - -- **Q1.** Do we keep `theme-neutral` or author a custom theme file that maps to - our tokens? (Decision: theme bridge in Phase 1 — we keep `theme-neutral` so we - inherit any future Astryx fixes, and the bridge maps our variables on top.) -- **Q2.** Do we replace `MetricCard` (our own) with Astryx's, or keep ours? (Decision: - keep ours — it encodes ACOS tone semantics that aren't in the Astryx API.) -- **Q3.** Astryx's `Selector` doesn't support `aria-current` on a parent group. - We add a `data-testid` to make testing possible. (Decision: add `data-testid` - in Phase 4 tests.) -- **Q4.** Does `MobileNav` need a separate redesign? (Decision: Phase 9 reuses the - same SideNav inside a Dialog/Drawer; no separate design pass.) - ---- - -## Acceptance criteria (whole plan) - -- [ ] `main` deploys at every phase boundary. -- [ ] 511+ tests passing at every phase boundary. -- [ ] Zero visual regression compared to the pre-migration screenshots. -- [ ] `npm run build` succeeds at every phase boundary. -- [ ] Phase 0 contract tests are the canonical regression check. -- [ ] All "9 contracts the previous migration broke" remain unbroken - (verified by a `__tests__/contracts/` smoke test file). diff --git a/docs/AUDIT-FOLLOWUPS.md b/docs/AUDIT-FOLLOWUPS.md deleted file mode 100644 index ad36930..0000000 --- a/docs/AUDIT-FOLLOWUPS.md +++ /dev/null @@ -1,106 +0,0 @@ -# Audit Follow-Ups - -This document tracks fixes applied in response to the security and quality -audit dated **2026-07-21** (report: `Amazon-ad-console-audit-2026-07-21-1.md`). -It is the canonical cross-reference between audit findings, the PRs that -closed them, and the resulting test coverage. - -## Release blockers — resolved - -| Audit ID | Finding | Resolution | PR | -|---|---|---|---| -| **B-01** | Vercel build failed with `Module not found: ../generated/prisma/client`; no `prisma generate` in the build path | Fixed on `main` before this remediation wave (commit history, not in this doc) | — | -| **B-02** | `new PrismaClient({} as any)` triggered `engine type "client" requires adapter or accelerateUrl` | Fixed on `main` (Postgres driver adapter) | — | -| **B-04** | SQLite is unsuitable for Vercel multi-instance cloud sync | Fixed on `main` (Postgres via `@prisma/adapter-neon`) | — | -| **B-03** | `/api/sync` ran `deleteMany` then `createMany` outside a transaction, so a single bad record could leave the user with an empty cloud account | Wrapped both in `prisma.$transaction(async tx => …)`; returns 500 with "previous cloud data preserved" on any failure; 12 new unit tests | [#24](https://github.com/projectamazonph/Amazon-ad-console/pull/24) | - -## High-priority findings — resolved - -| Audit ID | Finding | Resolution | PR | -|---|---|---|---| -| **H-01** | The campaign-creation wizard dropped `startDate`, `endDate`, `placements`, `adFormat`, ASIN/Category/Audience targets, and SB/SD creative fields between the draft and the persisted campaign | `launchCampaign` now passes the full draft through. ASIN/Category/Audience target inputs are parsed with the existing `parseKeywords` helper. 9 new tests pin each round-tripped field. | [#26](https://github.com/projectamazonph/Amazon-ad-console/pull/26) | -| **H-02** | The Campaign Manager metric block was shifted one column to the left: the cell under "CPC" actually showed spend, "Spend" showed sales, "Sales" showed orders, and "Orders" showed CPC. Dangerous in a training product. | Reordered the 4 `<td>`s in `ManagerCampaignsTab.tsx`. 3 new tests with pairwise-distinct metric values so any future shift fails loudly. | [#25](https://github.com/projectamazonph/Amazon-ad-console/pull/25) | -| **H-03** | The 6 advertised training-product pages (Drills, Missions, Reports, Bulk ops, Trainer, Integrity) had no nav item or UI control calling `setView()` for any of them — the only way to reach them was to mutate the store by hand | Added a `Training` section to `GLOBAL_NAV` (lands on drills), a shared `TRAINING_RAIL` exposing all 6 training views, and pure helpers `activeTopbarSection(view)` and `sidebarSectionForView(view)` so Topbar/Sidebar/MobileNav share one source of truth. 10 new tests. "← Back to campaigns" added to 5 of the 6 pages. | [#27](https://github.com/projectamazonph/Amazon-ad-console/pull/27) | - -## Open findings — not yet addressed - -The following findings are documented in the audit but were intentionally -left out of this remediation wave. They are listed here so the next person -picking up the work has a starting checklist. - -### High-priority - -- **H-04** Reports are fabricated and misaligned. Report rows are randomized - and do not reflect the user's campaigns; search-term and placement - reports always contain zero rows; the queue/report id mismatch causes - the "View" button to select an ID no report owns; the table renders - 9 headers but only 5 data cells; CSV values are not escaped and object - URLs are not revoked. -- **H-05** Drills and Missions do not observe real user actions. `evaluateDrillAction` - is read but never called; the "Skip step" button is always visible even on - `skippable: false` steps; "Back to drills" does not reset session state; - Mission steps advance through a self-attested "Complete step" button. -- **H-06** Simulation violates metric roll-up invariants. Campaign metrics - are generated independently then split across targets with rounding, - so campaign totals, sum-of-targets, and sum-of-ad-groups can disagree. -- **H-07** Local and authenticated user data are not isolated. Every - visitor uses the same `ad-console-storage` localStorage key; sign-in/out - does not reset local campaign state; one account can see and upload - another user's campaigns on a shared browser. -- **H-08** Tailwind classes are absent from dependencies, leaving landing / - auth / account / sync UI unstyled. **Status: resolved on `main`** - (commit `f1f72d4` removed Tailwind and added the missing CSS classes). -- **H-09** Mobile navigation dropped tab intent. **Status: resolved on - `main`** (HEAD `a7a39e0` fixed sidebar/mobile nav overlaps and - duplicate Create campaign button). -- **H-10** Campaign status UI and child-status logic are incorrect: the - Overview select ignores the chosen value, archived campaigns show an - "Enable" button, and parent pause/enable overwrites every child status. -- **H-11** API input and output contracts are unsafe: registration has no - email canonicalization, password strength, rate limit, or content-type - validation; campaign POST/PUT accepts arbitrary types/statuses; `dailyBudget || 25` - silently coerces 0; direct `JSON.parse` can turn one corrupt row into a - 500. Sync route was hardened in B-03; the rest remains. -- **H-12** Dockerfile cannot produce the configured runtime image: no - `prisma generate`; copies `.next/standalone` but `next.config.ts` does - not enable `output: 'standalone'`; contradictory `npm ci` flags. - -### Medium-priority - -The 14 medium findings (M-01 through M-14) cover empty-state traps, draft -engine divergence, duplicate implementations, hydration mismatches, the -bulk CSV parser, placement validation, the cloud download reconcile path, -no URL model for views, clickable `div`s instead of buttons, two -unrelated `actionLog` fields, read-only negatives, and duplicate -campaign-lifecycle code paths. - -## Documentation drift — fixed in this wave - -| Claim | Was | Now | -|---|---|---| -| README seed count | "4 pre-built training campaigns" | 6 (table with names and targeting modes) | -| README "Training Features" | 6 bullets with no nav mention | Adds a Training global-nav section; explicitly notes reachability | -| `docs/FEATURES.md` training sections | No reachability info | Each training section now has a `Reachable from:` line | -| `.env.example` | Documented as "client-only" with no DATABASE_URL | Already correct on `main` (DATABASE_URL + AUTH_SECRET documented) | - -## Test growth - -| Stage | Files | Tests | -|---|---:|---:| -| Pre-audit (audit baseline) | 27 | 448 | -| After B-03 (#24) | 28 | 464 | -| After H-02 (#25) | 29 | 467 | -| After H-01 (#26) | 30 | 476 | -| After H-03 (#27) | 30 | 486 | - -## How to read this document - -- The **Resolved** tables map audit ID → PR → behaviour change. Each PR - contains a failing test added before the fix, the production code - change, and a green run of the full suite. -- The **Open findings** section is the next picklist. Items marked - **resolved on `main`** were fixed by commits that landed before this - remediation wave; they are kept in the table so the next person can - grep the report and immediately see the status. -- The **Test growth** table is a quick way to verify each PR landed - without regressions. diff --git a/docs/AUTH.md b/docs/AUTH.md deleted file mode 100644 index c626440..0000000 --- a/docs/AUTH.md +++ /dev/null @@ -1,351 +0,0 @@ -# Multi-User Authentication Guide - -This document covers the multi-user access system for the Amazon Ad Console Training Simulator. - -## Overview - -The application supports multiple users with isolated campaign data. Each user can: -- Register with email/password -- Login/logout securely -- Save campaigns to the database -- Load campaigns from any device -- Maintain separate training progress - -## Architecture - -### Components - -1. **NextAuth v5** — Authentication provider -2. **Prisma** — Database ORM -3. **Postgres** — Database (via `@prisma/adapter-neon`), used in every environment -4. **JWT Sessions** — Stateless session management - -### Database Schema - -```prisma -model User { - id String @id @default(cuid()) - email String @unique - name String? - passwordHash String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - campaigns Campaign[] - simulations Simulation[] -} - -model Campaign { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - campaignId String - type String - name String - // ... all campaign fields as JSON - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([userId, campaignId]) -} -``` - -## Setup - -### 1. Install Dependencies - -```bash -npm install prisma @prisma/client @prisma/adapter-neon @neondatabase/serverless next-auth bcryptjs -npm install -D @types/bcryptjs -``` - -### 2. Initialize Prisma - -```bash -npx prisma init --datasource-provider postgresql -``` - -### 3. Configure Environment Variables - -Create `.env` file: - -```env -# Prisma (Postgres — e.g. from Vercel Storage → Postgres, or Neon directly) -DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require" - -# NextAuth/Auth.js session secret — generate with: openssl rand -base64 32 -AUTH_SECRET="your-secret-key-here" -``` - -### 4. Run Migrations - -```bash -npx prisma migrate dev --name init -npx prisma generate -``` - -## API Routes - -### Authentication - -#### Register User -``` -POST /api/auth/register -``` - -Request body: -```json -{ - "email": "user@example.com", - "password": "securepassword", - "name": "John Doe" -} -``` - -Response: -```json -{ - "message": "User created", - "userId": "clx1234567890" -} -``` - -#### Login -NextAuth handles login via: -``` -POST /api/auth/[...nextauth] -``` - -### Campaign Management - -#### List Campaigns -``` -GET /api/campaigns -``` - -Returns all campaigns for the authenticated user. - -#### Create Campaign -``` -POST /api/campaigns -``` - -Request body: -```json -{ - "campaignId": "C-SP-123456", - "type": "SP", - "name": "My Campaign", - "dailyBudget": 25, - "defaultBid": 0.75 -} -``` - -#### Update Campaign -``` -PUT /api/campaigns/[id] -``` - -#### Delete Campaign -``` -DELETE /api/campaigns/[id] -``` - -### Data Sync - -#### Sync All Campaigns -``` -POST /api/sync -``` - -Request body: -```json -{ - "campaigns": [...] -} -``` - -#### Load All Campaigns -``` -GET /api/sync -``` - -## Frontend Components - -### SessionProvider -Wraps the app to provide session context: - -```tsx -// src/components/SessionProvider.tsx -'use client'; - -import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react'; - -export function SessionProvider({ children }) { - return ( - <NextAuthSessionProvider> - {children} - </NextAuthSessionProvider> - ); -} -``` - -### UserMenu -Displays user info and logout: - -```tsx -// src/components/UserMenu.tsx -'use client'; - -import { useSession, signOut } from 'next-auth/react'; - -export function UserMenu() { - const { data: session, status } = useSession(); - - if (!session) { - return ( - <div className="flex items-center gap-3"> - <Link href="/auth/login">Sign in</Link> - <Link href="/auth/register">Sign up</Link> - </div> - ); - } - - return ( - <div className="relative"> - <button onClick={() => signOut({ callbackUrl: '/' })}> - Sign out - </button> - </div> - ); -} -``` - -### SyncButton -Handles cloud sync: - -```tsx -// src/components/SyncButton.tsx -'use client'; - -import { useSession } from 'next-auth/react'; -import { useAdConsoleStore } from '@/engine/ad-console/store'; - -export function SyncButton() { - const { data: session } = useSession(); - - const handleSync = async (direction: 'upload' | 'download') => { - if (direction === 'upload') { - const state = useAdConsoleStore.getState().state; - await fetch('/api/sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ campaigns: state.campaigns }), - }); - } else { - const response = await fetch('/api/sync'); - const campaigns = await response.json(); - useAdConsoleStore.setState((s) => ({ - state: { ...s.state, campaigns }, - })); - } - }; - - return ( - <div className="flex items-center gap-2"> - <button onClick={() => handleSync('upload')}>↑ Save</button> - <button onClick={() => handleSync('download')}>↓ Load</button> - </div> - ); -} -``` - -## Pages - -### Login Page -`/auth/login` — Email/password login form - -### Register Page -`/auth/register` — New user registration form - -### Landing Page -`/landing` — Public landing page with login/register links - -## Security Considerations - -### Password Hashing -- Uses bcryptjs with 10 salt rounds -- Never store plaintext passwords - -### JWT Sessions -- Stateless authentication -- Tokens stored in HTTP-only cookies -- Configurable expiration - -### Data Isolation -- Each user's data filtered by `userId` -- API routes verify ownership before operations -- No cross-user data access - -### Input Validation -- Server-side validation on all endpoints -- Email format validation -- Password strength requirements (minimum 6 characters) - -## Production Deployment - -### Database -`prisma/schema.prisma` declares the `postgresql` datasource provider, used in every environment, not just production. The runtime connection (`DATABASE_URL` plus the `@prisma/adapter-neon` driver adapter) is wired up separately in `prisma.config.ts` / `src/lib/prisma.ts`, not in the schema itself — see `.env.example`: - -```env -DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require" -``` - -### Environment Variables -```env -DATABASE_URL="your-postgres-connection-string" -AUTH_SECRET="strong-random-secret" # generate with: openssl rand -base64 32 -``` - -### Security Checklist -- [ ] Use strong AUTH_SECRET (32+ characters) -- [ ] Enable HTTPS in production -- [ ] Set secure cookie flags -- [ ] Add rate limiting to auth endpoints -- [ ] Implement CSRF protection -- [ ] Add account lockout after failed attempts -- [ ] Enable email verification (optional) - -## Troubleshooting - -### Common Issues - -**"Invalid email or password"** -- Check if user exists in database -- Verify password hash is correct -- Check for typos in email - -**"Unauthorized" error** -- Ensure user is logged in -- Check JWT token expiration -- Verify AUTH_SECRET is set - -**Database connection errors** -- Local development: run `npx prisma migrate dev` to apply pending migrations -- Production: run `npx prisma migrate deploy` instead (`migrate dev` is dev-only — it can prompt interactively and isn't safe for CI/deploy pipelines) -- Check DATABASE_URL in .env points at a reachable Postgres instance - -### Debug Mode -Enable NextAuth debug logging: - -```env -AUTH_DEBUG=true -``` - -## Future Enhancements - -- [ ] OAuth providers (Google, GitHub) -- [ ] Email verification -- [ ] Password reset flow -- [ ] Two-factor authentication -- [ ] Team/organization support -- [ ] Role-based access control diff --git a/docs/CLEANUP-PLAN.md b/docs/CLEANUP-PLAN.md deleted file mode 100644 index 4f64c84..0000000 --- a/docs/CLEANUP-PLAN.md +++ /dev/null @@ -1,41 +0,0 @@ -# Ponytail Cleanup Plan - -## Summary - -This plan documents the ponytail (lazy senior dev) cleanup pass applied to the codebase. -The goal was to remove over-engineering and simplify patterns that don't earn their complexity. - -## Changes Made - -### 1. Barrel File Collapse -- Deleted `src/engine/ad-console/engine.ts` and `src/engine/ad-console/core/engine.ts` -- Updated 12 component imports from `@/engine/ad-console/engine` to `@/engine/ad-console/core/engine` -- Reduces import indirection from 3 layers to 1 - -### 2. Slice Boilerplate DRY -- Created `src/engine/ad-console/core/slices/helpers.ts` with `campaignMutator` and `campaignMutatorObj` -- Refactored 4 slice files (adgroup, budget, target, negative) to use the helpers -- Each slice method is now a one-liner instead of a 2-3 line wrapper - -### 3. Deduplicate makeDraft -- Removed duplicate `makeDraft()` from `core.ts` -- Now imports from `draft.ts` which exports it -- Removed duplicate `toggleCampaignStatus`, `archiveCampaign`, `duplicateCampaign` from core.ts - -### 4. Replace mobileMenuReducer -- Replaced 4-state reducer with `mobileMenuOpen: boolean` -- Simplified MobileNav component — removed animation state tracking -- Removed `mobileMenuReducer`, `MobileMenuState`, `MobileMenuAction` from engine - -### 5. Simplify search-term-generator -- Replaced 3 Strategy Pattern classes with flat functions -- Removed `SearchTermGenerator` type and registry -- Rewrote tests to use function API - -### 6. Clean Root Directory -- Moved 22 legacy files to `legacy/` directory -- Deleted `dev.db` and added `*.db` to `.gitignore` - -## Verification - -All 470 tests pass, tsc clean (only pre-existing .next/ cache error). diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md deleted file mode 100644 index e7f7584..0000000 --- a/docs/DEPLOYMENT.md +++ /dev/null @@ -1,36 +0,0 @@ -# Deployment (Vercel) - -## Project - -- Vercel project: `amazon-ad-console` (team: `ryandabao1982s-projects`) -- Framework: Next.js, built with Turbopack -- Git integration: GitHub `projectamazonph/Amazon-ad-console`, production tracks the `main` branch -- No custom domain configured — production is served on `*.vercel.app` aliases only - -## Branch protection - -`main` requires a pull request and a passing `Type-check, test, build` status check. -Direct pushes to `main` (including empty/no-op commits) are rejected by GitHub rules — -all changes, including deploy-trigger commits, must go through a PR. - -## Triggering a redeploy - -There is no API/CLI action available in this repo's tooling that redeploys an existing -Vercel deployment by ID or deletes a project. To redeploy production: - -1. Open https://vercel.com/ryandabao1982s-projects/amazon-ad-console/deployments -2. Find the deployment aliased to production (top of the list, `target: production`) -3. Open its "⋯" menu → **Redeploy** - -This re-runs the build for that exact deployment's commit without needing a new push. - -To deploy a *different* commit to production instead, merge a PR into `main` — the -GitHub integration builds and promotes it automatically once CI passes. - -## Known gotcha: production can drift from `main` - -Production is whatever deployment is currently aliased to it — that isn't always -`main`'s latest commit. A deployment can be promoted to production from any branch -(e.g. via a manual "Redeploy" of an older build, or a preview deployment promoted by -hand). Before assuming production matches `main` HEAD, check the deployment's -`meta.githubCommitRef` and `meta.githubCommitSha` against `git log main -1`. diff --git a/docs/FEATURES.md b/docs/FEATURES.md deleted file mode 100644 index aa596aa..0000000 --- a/docs/FEATURES.md +++ /dev/null @@ -1,529 +0,0 @@ -# Feature Documentation - -Detailed documentation for each module in the Amazon Ad Console Training Simulator. - ---- - -## 1. Dashboard - -**Component**: `Dashboard.tsx` (133 lines) -**View**: `dashboard` - -The aggregate metrics view showing totals across all enabled campaigns. - -### Metrics Displayed -- **Total Impressions** — sum of all enabled campaign impressions -- **Total Clicks** — sum of all enabled campaign clicks -- **Total Spend** — sum of all enabled campaign spend -- **Total Sales** — sum of all enabled campaign sales -- **Total Orders** — sum of all enabled campaign orders -- **Average ACoS** — spend / sales × 100 -- **Average ROAS** — sales / spend -- **Average CTR** — clicks / impressions × 100 - -### Rollup Logic -Metrics flow bottom-up: -1. **Targets** (per-keyword) have individual metrics -2. **Ad Groups** sum their child targets' metrics -3. **Campaigns** sum their child ad groups' metrics -4. **Dashboard** sums all enabled campaigns' metrics - -The `totalMetrics()` function in `core/engine.ts` handles the campaign → dashboard aggregation. The `simulateDays()` function handles the target → ad group → campaign cascade. - ---- - -## 2. Campaign Manager - -**Component**: `CampaignManager.tsx` (300 lines) -**View**: `campaigns` - -List view of all campaigns with filtering and bulk actions. - -### Features -- **Filter by type**: All / SP / SB / SD -- **Filter by status**: All / Enabled / Paused / Archived -- **Filter by portfolio**: All / any portfolio name -- **Search**: Free-text across name, type, targeting mode, portfolio, ad format -- **Per-campaign actions**: Toggle status, archive, duplicate, view details -- **Inline metrics**: Impressions, clicks, spend, sales, ACoS, ROAS - -### Interaction Flow -1. Click a campaign row → navigates to `CampaignDetail` -2. Toggle button → calls `toggleCampaignStatus` -3. Archive button → calls `archiveCampaign` -4. Duplicate button → calls `duplicateCampaign` - ---- - -## 3. Campaign Detail - -**Component**: `CampaignDetail.tsx` (550+ lines) -**View**: `detail` - -Deep-dive view for a single campaign with tabbed sub-views. - -### Tabs -- **Overview** — campaign settings, metrics, creative status -- **Ad Groups** — list of ad groups with CRUD actions and drill-down - - **CRUD operations** (inline in the table): - - **Create**: Enter a name in the "New ad group name" field and click "+ Add ad group" - - **Rename**: Click the inline edit icon next to the ad group name - - **Status**: Toggle via inline dropdown (Enabled / Paused / Archived); cascades to all targets in the group - - **Default bid**: Set via inline numeric input; clamped to ≥ $0.02 - - **Delete**: Click the "Remove" button (disabled if it's the only ad group); removes the group and all its targets - - **Drill-down**: Click an ad group row to see its child targets in a focused sub-view with a "← All ad groups" back button. The drill-down shows: - - Ad group name, status dropdown, default bid editor with Save button - - Full targets table filtered to that ad group -- **Targets** — keyword/product targets with bid management -- **Search Terms** — customer search terms with harvest/negate actions -- **Negatives** — negative keyword list -- **Placements** — placement bid adjustments -- **Budget Rules** — schedule/performance-based rules with full CRUD - - **Add rule**: Form with name, type (Schedule/Performance), budget increase multiplier, condition text - - **Edit rule**: Inline editable name, type dropdown, increase amount, condition text - - **Remove rule**: Delete button with confirmation dialog - - **Validation**: Type must be Schedule or Performance, increase must be positive, name and condition required -- **Change History** — chronological log of all changes - -### Target Management -- **Add keyword**: Opens form for keyword text, match type, bid -- **Remove target**: Removes target from campaign -- **Set bid**: Set exact CPC bid on a target -- **Adjust bid**: Multiply current bid by a factor -- **Pause target**: Sets target status to Paused - -### Search Term Actions -- **Harvest**: Promotes a converting search term to an Exact keyword target and adds a Phrase negative to prevent duplicate matching -- **Negate**: Adds the search term as a Negative exact keyword - -### Placement Adjustments -Three placement types with percentage bid modifiers: -- **Top of Search** — premium placement at top of results -- **Product Pages** — ads on product detail pages -- **Rest of Search** — all other placements - ---- - -## 4. Create Campaign Wizard - -**Component**: `CreateCampaignWizard.tsx` (280+ lines) -**View**: `create` - -Multi-step campaign creation wizard mimicking the Amazon Ads Console flow. - -### Steps -1. **Ad type**: Select SP, SB, or SD -2. **Basics**: Campaign name, portfolio, status, daily budget, start date, ad format -3. **Products & creative**: Select ASINs from a checkable product catalog table; enter brand name and headline for SB/SD campaigns -4. **Targeting**: Choose targeting mode with auto-targeting context panel; enter keywords (one per line) for manual modes, or ASIN/audience targets for product/contextual targeting -5. **Bidding & budget**: Bid strategy, default bid, and placement adjustments (Top of Search / Product pages / Rest of Search) -6. **Review & launch**: Full settings summary; keywords and product counts shown; launch creates the campaign with parsed keywords as targets - -### Validation -- Campaign name is required -- Daily budget must be ≥ $1 -- Default bid must be ≥ $0.02 -- Keywords are parsed one-per-line from the text area - -### Launch -Creates a normalized campaign via `normalizeCampaign()` and prepends it to the campaigns array. Immediately navigates to the detail view. - ---- - -## 5. Portfolio Overview - -**Component**: `PortfolioOverview.tsx` (136 lines) -**View**: `portfolio` - -Campaigns grouped by portfolio with aggregate metrics per group. - -### Groups -- Each unique portfolio name becomes a group -- Campaigns without a portfolio go into "Training Portfolio" (default) -- Each group shows aggregate metrics and campaign count -- Click a campaign within a group → navigates to detail view - ---- - -## 6. Guided Drills - -**Component**: `DrillsPage.tsx` (126 lines) -**Engine**: `features/drills/engine.ts` -**View**: `drills` -**Reachable from**: topbar `Training` section (lands here by default) and the Training left rail - -Click-by-click navigation coaching through real Amazon Ads Console workflows. - -### Available Drills - -| ID | Title | Ad Type | Difficulty | Steps | -|----|-------|---------|-----------|-------| -| `nav-sp-search-term-negative` | Find and block waste from Search terms | SP | Beginner | 5 | -| `nav-sp-placement-controls` | Adjust SP placement settings | SP | Beginner | 5 | -| `nav-sb-creative-review` | Review SB creative before launch | SB | Intermediate | 4 | -| `nav-report-request` | Request and copy a performance report | SP | Beginner | 3 | -| `nav-sd-audience-path` | Find Sponsored Display audience targeting | SD | Intermediate | 4 | - -### How It Works -1. User selects a drill from the list -2. Sidebar shows step-by-step instructions -3. User clicks the target action in the simulator -4. Engine evaluates: correct → advance; incorrect → record mistake -5. Skippable steps allow skipping without penalty -6. Score calculated: `100 - (mistakes × 15) - (skips × 5)`, min 0 -7. Results stored in `drillResults` history - ---- - -## 7. Training Missions - -**Component**: `MissionsPage.tsx` (68 lines) -**View**: `missions` -**Reachable from**: topbar `Training` section and the Training left rail -**Engine**: `features/missions/engine.ts` -**View**: `missions` - -Scenario-based challenges that test real campaign management skills. - -### Available Missions - -| ID | Title | Difficulty | Steps | -|----|-------|-----------|-------| -| `mission-optimize-acos` | Optimize Campaign ACoS Below Target | Advanced | 6 | -| `mission-launch-manual` | Launch a Manual SP Campaign from Scratch | Intermediate | 5 | -| `mission-cleanup-waste` | Clean Up Wasted Spend | Beginner | 4 | - -### Scoring -- Starts at 100 points -- Each hint used: -10 points -- Complete all steps to finish -- Final score reflects efficiency (fewer hints = higher score) - ---- - -## 8. Reports - -**Component**: `ReportsPage.tsx` (89 lines) -**View**: `reports` -**Reachable from**: topbar `Training` section and the Training left rail -**Engine**: `features/reports/engine.ts` -**View**: `reports` - -Generate and export performance reports matching Amazon Ads Console report types. - -### Report Types - -| Type | Description | -|------|------------| -| `campaign` | Campaign-level performance summary | -| `adGroup` | Ad group breakdown | -| `target` | Keyword/target performance | -| `searchTerm` | Customer search term data | -| `placement` | Placement-level breakdown | - -### Workflow -1. Select report type -2. Click "Request report" -3. Report generates immediately (simulated) -4. View report data in table format -5. Click "Export CSV" to download - ---- - -## 9. Bulk Operations - -**Component**: `BulkOpsPage.tsx` (85 lines) -**View**: `bulk` -**Reachable from**: topbar `Training` section and the Training left rail -**Engine**: `features/bulk/engine.ts` -**View**: `bulk` - -Import and validate Amazon Ads bulk CSV operations. - -### CSV Format -``` -Entity,Operation,Id,Campaign Name,Field,Value -campaign,update,C-SP-001,,DailyBudget,50 -target,pause,,C-SP-001,Status,Paused -negative,create,,C-SP-001,Keyword,irrelevant term -``` - -### Supported Operations -- **campaign**: update, pause, enable, archive -- **adGroup**: update, pause, enable -- **target**: update, pause, enable, delete -- **negative**: create, delete -- **budgetRule**: create, delete - -### Validation -The `validateBulkRows()` function checks: -- Required fields per entity type -- Valid operation names -- Valid field names -- Value format matching (e.g., bid must be numeric) - -Returns row-level error messages with specific field and reason. - ---- - -## 10. Integrity Center - -**Component**: `IntegrityPage.tsx` (76 lines) -**View**: `integrity` -**Reachable from**: topbar `Training` section and the Training left rail -**Engine**: `features/integrity/engine.ts` -**View**: `integrity` - -Automated data-quality auditing of campaign setup. - -### Checks Performed - -| Check | Severity | Description | -|-------|----------|------------| -| Archived campaign has active children | Error | Targets in archived campaigns should also be archived | -| Duplicate target IDs | Error | Each target must have a unique ID | -| Orphaned search terms | Warning | Search terms should link to a target (except SD) | -| SD with search terms | Warning | SD campaigns use audience reports, not search terms | -| SB rejected creative | Error | Creative must be approved before campaign can run | -| Low-inventory product | Warning | Campaigns promoting low-stock items may waste spend | - -### Scoring -- Score starts at 100 -- Each error: -15 points -- Each warning: -5 points -- Passes at ≥70 - -### Self-Heal Recommendations -Each issue includes a `recommendation` string explaining how to fix the problem. - ---- - -## 11. Trainer Dashboard - -**Component**: `TrainerPage.tsx` (124 lines) -**View**: `trainer` -**Reachable from**: topbar `Training` section and the Training left rail -**Engine**: `features/trainer/engine.ts` -**View**: `trainer` - -Supervisor view for monitoring trainee progress. - -### Certification Checklist -9-item checklist tracking trainee competency: - -1. Names ad type before making changes -2. Checks campaign status before editing -3. Checks date range before reading performance -4. Reads spend, sales, orders, ACoS, CPC, CVR -5. Explains why a target gets increased, decreased, paused, harvested, or negated -6. Uses exact negatives for precise waste, phrase only when safe -7. Validates SB creative fields before launch -8. Understands SD audience/contextual targeting vs keyword targeting -9. Checks change history after major edits - -### Action Grading -Each simulator action is automatically graded: -- **Good** (green): Creating campaigns, adding keywords, harvesting terms, pausing waste -- **Bad** (red): Deleting without reason, ignoring metrics, wrong match type -- **Warn** (yellow): Pausing broadly, adjusting bids without analysis - -### Notes -Trainer can add timestamped notes for each trainee session. - ---- - -## 12. Multi-User Profiles - -**Engine**: `features/profiles/engine.ts` -**View**: Integrated into sidebar - -Separate training state per trainee. - -### Features -- Default profile: "Trainee" (`p-default`) -- Create new profiles with custom names -- Switch between profiles (updates `lastActiveAt`) -- Rename profiles -- Delete profiles (falls back to first available) - ---- - -## 13. Persistence & State Management - -**Engine**: `store.ts` — Zustand persist middleware -**View**: Automatic (no user-facing component) - -### LocalStorage Persistence -The Zustand store uses `persist` middleware from `zustand/middleware` to save and restore state across page refreshes. - -**Storage key**: `ad-console-storage` (localStorage) - -**Persisted data**: -- All campaigns, ad groups, targets, search terms, negatives, budget rules -- Portfolio assignments and portfolio name list -- Filter preferences, simulation days, action log -- State version string - -**Not persisted** (UI-only transient state): -- Draft/campaign creation wizard state -- Current view, selected tab, selected campaign ID -- Mobile menu status -- Feature engine state (drills, missions, profiles, etc.) - -### Export/Import -The store exposes `exportState()` and `importState(json)` for manual backup/restore: -- `exportState()` serializes all core state to a JSON string -- `importState()` deserializes and validates the JSON (rejects empty strings and non-object values) -- Returns `true` on success, `false` on parse failure - -### Sidebar Navigation Wiring -Left-rail sidebar items now map to campaign detail tabs: -- **Campaigns** → campaign list view -- **Ad groups** → `adgroups` tab in campaign detail -- **Targeting** → `targets` tab -- **Search terms** → `searchTerms` tab -- **Negative keywords** → `negatives` tab -- **Budget rules** (Portfolios section) → `budgetRules` tab - -When clicking a tab-mapped item: -- If user is already viewing a campaign detail → switches the active tab -- If user is in the campaign list → navigates to detail view and switches tab -- Items without a tab → plain view navigation (unchanged behaviour) - ---- - -## 14. Mobile & Responsive Layout - -**Component**: `MobileNav.tsx` (133 lines) -**Hook**: `useBreakpoint.ts` (52 lines) -**Engine**: `core/engine.ts` — `resolveBreakpoint`, `mobileMenuReducer`, `isTouchViewport` -**View**: Integrated into all views via topbar - -### Breakpoints - -| Range | Label | Behavior | -|-------|-------|----------| -| < 768px | Mobile | Desktop sidebar hidden; hamburger toggle + slide-out drawer | -| 768–1100px | Tablet | Same as mobile — desktop sidebar hidden, hamburger drawer takes over (both are `isMobileOrTablet` in `useBreakpoint`) | -| > 1100px | Desktop | Full Amazon Console layout with the persistent left sidebar | - -### Mobile Drawer -- Hamburger button in the global nav toggles a slide-out drawer -- Drawer contains all sidebar groups for the active section: Campaign Manager, Portfolios, Measurement, or Training (Drills/Missions/Reports/Bulk ops/Trainer/Integrity) -- Backdrop overlay with click-to-close -- Escape key closes the drawer -- Animation state machine: closed → open ↔ closing → closed -- Touch-friendly `44px` minimum tap targets on all interactive elements - -### Touch Viewport -- `isTouchViewport()` in the engine detects devices with coarse pointer at ≤ 1100px -- Touch-action: manipulation on interactive elements prevents tap delay -- -webkit-overflow-scrolling: touch on scrollable panels - -### Responsive Adjustments -- **Nav**: Brand text truncates, nav account text truncates, section text hidden -- **Tables**: Horizontal scroll, smaller padding, smaller pill badges -- **Forms**: Full-width input stacking, 44px min-height on inputs -- **Tabs**: Horizontal overflow scroll with hidden scrollbar -- **Toolbar**: Wrap on multiple lines, flex-grow on filter controls -- **Page titles**: Stack vertically on mobile - ---- - -## 15. Multi-User Authentication - -**Auth Provider**: NextAuth v5 -**Database**: Prisma + Postgres (via `@prisma/adapter-neon`) -**Components**: `SessionProvider.tsx`, `UserMenu.tsx`, `SyncButton.tsx` -**Pages**: `/auth/login`, `/auth/register`, `/landing` - -### Overview -Multi-user access system allowing multiple trainees to have isolated campaign data with cloud synchronization. - -### Authentication Flow -1. **Registration**: User creates account with email/password -2. **Login**: User signs in with credentials -3. **Session**: JWT token stored in HTTP-only cookie -4. **Logout**: Session destroyed, redirect to home - -### Database Schema -- **User**: id, email, name, passwordHash, timestamps -- **Campaign**: All campaign data linked to userId -- **Simulation**: Simulation history linked to userId - -### API Routes -- `POST /api/auth/register` — Create new user -- `GET/POST /api/campaigns` — List/create campaigns -- `GET/PUT/DELETE /api/campaigns/[id]` — Single campaign CRUD -- `GET/POST /api/sync` — Bulk sync campaigns to/from database - -### Frontend Components -- **SessionProvider**: Wraps app for client-side session access -- **UserMenu**: Shows avatar, name, dropdown with sign out -- **SyncButton**: "Save" and "Load" buttons for cloud sync - -### User Flow -1. Visit `/auth/register` to create account -2. Sign in at `/auth/login` -3. Use simulator normally -4. Click "Save" to persist campaigns to database -5. Click "Load" to restore campaigns from any device - -### Security -- Password hashing with bcrypt (10 rounds) -- JWT sessions with secure HTTP-only cookies -- User data isolation via userId foreign key -- API route protection via session checks - ---- - -## 16. Landing Page - -**Component**: `landing/page.tsx` -**Route**: `/landing` - -### Features -- Public landing page with auth links -- Feature showcase with animations -- Stats display -- CTA to simulator -- Responsive design - -### Design -- Dark theme with zinc-950 background -- Emerald accent color -- Motion animations via `motion/react` -- Mobile-optimized layout - ---- - -## 17. Premium UI Redesign - -**File**: `globals.css` - -### Design System -- **Typography**: Geist font stack with refined type scale -- **Colors**: Amazon-faithful palette with improved contrast -- **Shadows**: Subtle, depth-aware shadow system -- **Borders**: Refined hairlines with light/strong variants -- **Radius**: Consistent corner radius scale - -### Component Upgrades -- **Buttons**: Better hover/active/disabled states -- **Cards**: Subtle border + shadow, hover elevation -- **Tables**: Sticky headers, better row hover -- **Forms**: Teal focus ring, proper select arrows -- **Pills**: Refined color system - -### Mobile Improvements -- 48px minimum touch targets -- Better drawer animation with blur backdrop -- Safe area padding for iPhone notch -- Improved responsive breakpoints - -### Accessibility -- Visible focus ring on all interactive elements -- Reduced motion support -- Better color contrast ratios diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md deleted file mode 100644 index 5019c77..0000000 --- a/docs/HANDOFF.md +++ /dev/null @@ -1,271 +0,0 @@ -# Amazon Ad Console — Campaign Creation System Handoff Document - -> **Source project:** `/root/Documents/Codex/2026-07-16/install-github/Amazon-ad-console` -> **Branch:** `main` (latest) -> **Last reviewed:** 2026-07-20 -> **Test status:** 298/305 passing (7 failing due to localStorage in test env), TypeScript compiles clean - ---- - -## 1. Architecture Overview - -Next.js + Zustand + pure TypeScript engine + Prisma/SQLite database. SOLID principles throughout. - -``` -src/ -├── engine/ad-console/ -│ ├── core/ -│ │ ├── types.ts — All domain interfaces -│ │ ├── engine.ts — Stateless pure functions (CRUD, simulation, metrics) -│ │ └── scenarios.ts — Seed data (product catalog, default campaigns) -│ ├── store.ts — Zustand root store (8 composed slices) -│ ├── index.ts — Public API re-exports -│ ├── engine.ts — Re-exports core/engine -│ ├── types.ts — Re-exports core/types -│ ├── scenarios.ts — Re-exports core/scenarios -│ └── features/*/ — Drill, profile, trainer, bulk, reports, missions, integrity -├── components/ -│ ├── AdConsole/ -│ │ ├── AdConsole.tsx — Root view switcher -│ │ ├── CampaignManager.tsx — Campaigns/AdGroups/Targets/SearchTerms/Negatives tabs -│ │ ├── CampaignDetail.tsx — Single campaign detail (8 tabs) -│ │ ├── CreateCampaignWizard.tsx — 6-step campaign creation wizard -│ │ ├── Dashboard.tsx — Summary metrics -│ │ ├── PortfolioOverview.tsx — Portfolio view -│ │ ├── metrics/MetricCard.tsx — Metric card display component -│ │ ├── layout/ -│ │ │ ├── Sidebar.tsx — Navigation rail -│ │ │ └── Topbar.tsx — Header with actions + UserMenu -│ │ ├── mobile/ -│ │ │ └── MobileNav.tsx — Mobile drawer navigation -│ │ ├── nav/ -│ │ │ └── consoleNav.ts — Amazon console nav model -│ │ └── features/*/ -│ ├── SessionProvider.tsx — NextAuth session wrapper -│ ├── UserMenu.tsx — User dropdown menu -│ └── SyncButton.tsx — Cloud sync controls -├── lib/ -│ ├── auth.ts — NextAuth configuration -│ ├── prisma.ts — Prisma client singleton -│ ├── validation.ts — Input validation helpers (ValidationError, assert*) -│ └── useBreakpoint.ts — Responsive breakpoint hook -├── generated/prisma/ — Prisma generated client -├── app/ -│ ├── layout.tsx — Root layout + SessionProvider -│ ├── page.tsx — Home → <AdConsole /> -│ ├── landing/page.tsx — Landing page with auth links -│ ├── auth/ -│ │ ├── login/page.tsx — Login page -│ │ └── register/page.tsx — Registration page -│ └── api/ -│ ├── auth/ -│ │ ├── [...nextauth]/route.ts — NextAuth API -│ │ └── register/route.ts — User registration -│ ├── campaigns/ -│ │ ├── route.ts — GET/POST campaigns -│ │ └── [id]/route.ts — GET/PUT/DELETE single campaign -│ └── sync/route.ts — Bulk sync campaigns to/from DB -└── prisma/ - ├── schema.prisma — Database schema (User, Campaign, Simulation) - └── migrations/ — Database migrations -tests/ — Additional engine tests -``` - -**Key principle:** Engine layer has zero React imports. Components have zero business logic. Validation is fail-fast — invalid state never propagates. - ---- - -## 2. Multi-User Access System - -### 2.1 Authentication -- **Provider**: NextAuth v5 with credentials (email/password) -- **Session**: JWT tokens stored in HTTP-only cookies -- **Password Hashing**: bcryptjs with 10 salt rounds -- **Database**: SQLite via Prisma ORM - -### 2.2 Database Schema -```prisma -model User { - id String @id @default(cuid()) - email String @unique - name String? - passwordHash String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - campaigns Campaign[] - simulations Simulation[] -} - -model Campaign { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - campaignId String // Original campaign ID from the engine - type String // SP, SB, SD - name String - // ... all campaign fields as JSON - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([userId, campaignId]) -} -``` - -### 2.3 API Routes -- `POST /api/auth/register` — Create new user -- `GET/POST /api/campaigns` — List/create campaigns -- `GET/PUT/DELETE /api/campaigns/[id]` — Single campaign CRUD -- `GET/POST /api/sync` — Bulk sync campaigns to/from database - -### 2.4 Frontend Components -- `SessionProvider` — Wraps app for client-side session access -- `UserMenu` — Shows avatar, name, dropdown with sign out -- `SyncButton` — "Save" and "Load" buttons to sync campaigns to cloud - ---- - -## 3. Campaign Creation Flow — Per Type (SP/SB/SD) - -### 3.1 Wizard Steps - -| Step | Name | SP | SB | SD | -|------|------|----|----|-----| -| 1 | Ad type | Choice card → sets `type` + defaults `adFormat` | Same | Same | -| 2 | Basics | Name, portfolio, budget, dates, status, ad format | Same | Same | -| 3 | Products & creative | Product catalog checkboxes + coach tip | Product catalog + Brand name, Headline, Destination | Product catalog + Brand name, Headline | -| 4 | Targeting | Automatic / Manual keyword (3 textareas) / Manual product | Keyword (3 textareas) / Product / Category | Contextual / Views remarketing / Purchases remarketing / Categories | -| 5 | Bidding | Dynamic bids / up-down / fixed | CPC only | CPC / CPC+CPM (audiences) | -| 6 | Review & launch | Full summary → `launchCampaign()` | Same | Same | - -### 3.2 Type-Specific Defaults (normalizeCampaign) - -| Field | SP | SB | SD | -|-------|----|----|-----| -| `targetingMode` | `Automatic` | `Keyword` | `Contextual` | -| `adFormat` | `Standard` | `Product collection` | `Auto generated` | -| `bidStrategy` | `Dynamic bids - down only` | `Cost per click` | `Cost per click` | -| `creative` | `null` | `{ brandName, logo, headline, ... }` | `{ brandName, logo, headline, ... }` | -| `searchTerms[]` | empty | empty | empty (explicitly) | -| `products` | `['B0TRAIN001']` | same | same | - -### 3.3 Simulation Differences - -| Type | ROAS Baseline | Quality Bonus Factors | -|------|---------------|----------------------| -| SP | 3.2 | negatives ×0.03, budget rules ×0.02, top placement >30% → +0.04 | -| SB | 2.7 | Same as SP | -| SD | 3.5 | Same + remarketing targeting → +0.05 | - -### 3.4 Match Type Search Term Generation (simulateDays) - -| Match Type | Generated Terms | ROAS Adj | -|-----------|----------------|----------| -| Exact | Exact keyword + singular/plural variant (2 terms) | 4.0× | -| Phrase | "organic kw", "best kw" (2 terms) | 2.5× | -| Broad | "cheap kw", "kw accessories", "kw deals" (3 terms) | 1.5× | - -Generated search terms are filtered against negatives via `isFilteredByNegative()`. - ---- - -## 4. Feature Implementation Status - -### 4.1 ✅ Fully Implemented - -| Feature | Files | Status | -|---------|-------|--------| -| **Match types (Exact/Phrase/Broad keywords)** | `types.ts`, `CreateCampaignWizard.tsx`, `store.ts`, `engine.ts` | Three textareas in Step 4, parsed into targets at launch, search terms generated per match type in `simulateDays()` | -| **Product selection UI** | `CreateCampaignWizard.tsx`, `CampaignDetail.tsx` | Catalog table + checkbox grid in Step 3, removable pills in detail | -| **Full metrics (Impr, Clicks, CPC, Spend, Sales, Orders, ACOS, ROAS)** | `CampaignManager.tsx`, `CampaignDetail.tsx` | All 8 views show the complete column set | -| **Negative keywords** | `NegativesTab.tsx`, `SearchTermsTab.tsx` | Add negative exact/phrase, negate from search terms | -| **Harvest terms** | `SearchTermsTab.tsx` | Convert search terms to exact keywords | -| **Budget rules** | `BudgetRulesTab.tsx` | Add/remove/update budget rules | -| **Placements** | `PlacementsTab.tsx` | Top of Search, Product pages, Rest of Search adjustments | -| **Portfolio management** | `PortfolioOverview.tsx` | View campaigns grouped by portfolio | -| **Ad group management** | `AdGroupsTab.tsx` | Add/rename/status/bid/remove ad groups | -| **Bulk operations** | `BulkOpsPage.tsx` | CSV import, validation, preview | -| **Reports** | `ReportsPage.tsx` | Generate/export CSV reports | -| **Training missions** | `MissionsPage.tsx` | Scenario-based challenges with scoring | -| **Guided drills** | `DrillsPage.tsx` | Click-by-click coaching | -| **Integrity checks** | `IntegrityPage.tsx` | Data quality auditing | -| **Multi-user auth** | `auth/`, `api/`, `SessionProvider.tsx` | Registration, login, cloud sync | -| **Landing page** | `landing/page.tsx` | Public landing with auth links | -| **Mobile responsive** | `globals.css`, `MobileNav.tsx` | Hamburger menu, touch-optimized | -| **SD Campaign Goal** | `Step4TargetingSD.tsx` | Awareness/Consideration/Conversions selector | -| **Placements hidden for SD** | `CampaignDetail.tsx` | Tab conditionally hidden | -| **Headline character counter** | `Step3ProductsCreativeSB.tsx`, `Step3ProductsCreativeSD.tsx` | 50 char limit with counter | -| **campaignGoal passthrough** | `core.ts` | Wired through launchCampaign | - ---- - -## 5. Test Coverage - -``` -16 test files, 239+ tests — all passing - -Core engine tests (engine.test.ts): 74 tests -Wizard engine tests (wizard-engine.test.ts): 7 tests -Ad group tests (adgroup.test.ts): 14 tests -Budget rules tests (budget-rules.test.ts): 12 tests -Portfolio tests (portfolio.test.ts): 8 tests -Responsive tests (responsive.test.ts): 18 tests -Persistence tests (persistence.test.ts): 8 tests -Feature tests (drills, profiles, etc.): 98 tests -``` - ---- - -## 6. Git State - -``` -Branch: main (up to date with origin/main) -Latest commit: 800c2ba refactor: premium UI redesign with refined typography, surfaces, and mobile support - -Recent commits: -- 800c2ba refactor: premium UI redesign with refined typography, surfaces, and mobile support -- e77e27e fix: remove AI design slop across all pages -- 0ab1fca docs: add comprehensive mobile redesign plan -- 1715571 feat: add login/register links to landing page navigation -- e858968 feat: add multi-user access with auth and database -``` - ---- - -## 7. Quick Start Commands - -```bash -# Install dependencies -npm install - -# Run database migrations -npx prisma migrate dev - -# Generate Prisma client -npx prisma generate - -# Run tests -npx vitest run - -# TypeScript check -npx tsc --noEmit - -# Start dev server -npm run dev -``` - ---- - -## 8. Documentation - -- [Architecture](ARCHITECTURE.md) — SOLID design, slice composition, data flow -- [API Reference](API.md) — All engine functions with signatures -- [Data Schema](SCHEMA.md) — TypeScript interfaces and data shapes -- [Features](FEATURES.md) — Detailed feature documentation -- [Integration Guide](INTEGRATION.md) — Porting to amph-v2 -- [Tech Specs](TECH-SPECS.md) — Dependencies, configuration, performance -- [Mobile Redesign Plan](MOBILE_REDESIGN_PLAN.md) — Mobile-first redesign strategy -- [Authentication Guide](AUTH.md) — Multi-user access setup and configuration - ---- - -*Document generated: 2026-07-20 | Codebase verified against HANDOFF.md* diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md deleted file mode 100644 index 21aaccc..0000000 --- a/docs/INTEGRATION.md +++ /dev/null @@ -1,213 +0,0 @@ -# Integration Guide — Porting to amph-v2 - -The Amazon Ad Console engine is designed to be portable. This guide covers integrating it into the amph-v2 project. - ---- - -## Prerequisites - -- amph-v2 running Next.js 16 + Zustand 5 -- Same TypeScript configuration (`@/*` path alias) -- Same dependency versions (React 19, Zustand 5) - -## Step 1: Copy the Engine - -The engine layer has **zero external dependencies** — it is pure TypeScript. - -```bash -# From Amazon-ad-console root: -cp -r src/engine/ad-console /path/to/amph-v2/src/engine/ad-console -``` - -This gives amph-v2: -``` -amph-v2/src/engine/ad-console/ -├── core/ -│ ├── types.ts # All domain interfaces -│ ├── engine.ts # Pure business logic -│ └── scenarios.ts # Training data -├── features/ -│ ├── drills/ # Navigation coaching -│ ├── profiles/ # Multi-user profiles -│ ├── trainer/ # Certification -│ ├── bulk/ # CSV import -│ ├── reports/ # Report generation -│ ├── missions/ # Scenario challenges -│ └── integrity/ # Data quality -├── store.ts # Composed Zustand store -├── index.ts # Public API -├── engine.ts # Backward-compat re-export -└── types.ts # Backward-compat re-export -``` - -## Step 2: Copy Components (Optional) - -If you want the full UI: - -```bash -cp -r src/components/AdConsole /path/to/amph-v2/src/components/AdConsole -``` - -## Step 3: Register in amph-v2 Engine Registry - -amph-v2 uses an engine registry pattern. Add the ad-console module: - -```ts -// amph-v2/src/engine/registry.ts -import { useAdConsoleStore } from './ad-console/store'; - -export const engines = { - // ... existing engines - 'ad-console': { - store: useAdConsoleStore, - name: 'Amazon Ads Console', - version: '3.5.0', - }, -}; -``` - -## Step 4: Add Route - -Create a page in amph-v2's app router: - -```tsx -// amph-v2/src/app/(training)/ad-console/page.tsx -import { AdConsole } from '@/components/AdConsole/AdConsole'; - -export default function AdConsolePage() { - return <AdConsole />; -} -``` - -## Step 5: Import Only What You Need - -The engine supports selective imports: - -### Full Engine -```ts -import { useAdConsoleStore } from '@/engine/ad-console/store'; -``` - -### Core Only (no feature modules) -```ts -import { calc, simulateDays, normalizeCampaign } from '@/engine/ad-console/core/engine'; -import type { Campaign, Metrics } from '@/engine/ad-console/core/types'; -``` - -### Single Feature -```ts -import { runIntegrityCheck } from '@/engine/ad-console/features/integrity/engine'; -import type { IntegrityReport } from '@/engine/ad-console/features/integrity/types'; -``` - -### Store Slices (for custom composition) -```ts -import { createDrillsSlice } from '@/engine/ad-console/features/drills/store'; -import { createIntegritySlice } from '@/engine/ad-console/features/integrity/store'; -``` - -## Architecture Compatibility - -### amph-v2 Existing Pattern -``` -amph-v2/src/engine/ -├── listing-audit/ -│ ├── types.ts -│ ├── engine.ts -│ └── scenarios.ts -├── keyword-research/ -│ ├── types.ts -│ └── engine.ts -├── str-triage/ -│ ├── types.ts -│ ├── engine.ts -│ └── scenarios.ts -├── registry.ts -└── scoring.ts -``` - -### After Integration -``` -amph-v2/src/engine/ -├── ad-console/ # NEW — the full module -│ ├── core/ -│ ├── features/ -│ ├── store.ts -│ └── index.ts -├── listing-audit/ # existing -├── keyword-research/ # existing -├── str-triage/ # existing -├── registry.ts # updated -└── scoring.ts # existing -``` - -## Style Considerations - -The ad-console uses global CSS (`globals.css`). For amph-v2 integration: - -### Option A: CSS Modules (Recommended) -Convert the global styles to CSS modules: -```css -/* amph-v2/src/components/AdConsole/AdConsole.module.css */ -.app-layout { display: flex; height: 100vh; } -.app-sidebar { width: 220px; background: var(--surface-100); } -/* ... */ -``` - -### Option B: Global CSS -Import the ad-console styles in amph-v2's global stylesheet: -```css -/* amph-v2/src/styles/globals.css */ -@import '../../components/AdConsole/styles/ad-console.css'; -``` - -### Option C: CSS Variables Only -The ad-console already uses CSS custom properties (`--ink-100`, `--surface-200`, etc.). Map these to amph-v2's design tokens: -```css -/* amph-v2/src/styles/variables.css */ -:root { - --ink-100: #0F1111; - --ink-200: #565959; - --surface-100: #FFFFFF; - --surface-200: #F7F8F8; - /* ... match amph-v2's existing palette */ -} -``` - -## Testing the Integration - -```bash -cd amph-v2 -npm run dev -# Navigate to /ad-console -``` - -Verify: -1. Dashboard loads with 4 default campaigns -2. Campaign manager shows filtering and search -3. Campaign detail shows all tabs (Overview, Targets, Search Terms, etc.) -4. Add/remove keywords works -5. Bid adjustments save correctly -6. Simulation generates cascading metrics -7. All 5 drills work end-to-end -8. Reports generate and export as CSV -9. Integrity check runs and shows issues -10. State persists during session (Zustand in-memory) - -## Zero-Dependency Guarantee - -The engine layer imports only: -- `zustand` — for store creation (can be replaced with any state manager) -- TypeScript standard library — `Date`, `Set`, `Math`, `Array`, `JSON` - -It does NOT import: -- React -- Next.js -- Any UI library -- Any database/ORM - -This means the engine can also run in: -- Node.js scripts -- Web Workers -- Server-side API routes -- CLI tools diff --git a/docs/MOBILE_REDESIGN_PLAN.md b/docs/MOBILE_REDESIGN_PLAN.md deleted file mode 100644 index 0c0d9d6..0000000 --- a/docs/MOBILE_REDESIGN_PLAN.md +++ /dev/null @@ -1,414 +0,0 @@ -# Mobile Redesign Plan - Amazon Ad Console - -## Executive Summary - -This document outlines the comprehensive mobile redesign for the Amazon Ad Console training simulator. The goal is to transform the current desktop-focused interface into a fully mobile-optimized experience while maintaining feature parity and training effectiveness. - ---- - -## Current State Analysis - -### What Works -- ✅ Mobile hamburger menu with slide-out drawer -- ✅ Responsive breakpoints (mobile < 768px, tablet 768-1100px, desktop > 1100px) -- ✅ Touch-friendly button sizes (min 44px) -- ✅ Horizontal scroll for tabs -- ✅ Hidden sidebar on mobile - -### Critical Issues - -| Issue | Severity | Impact | -|-------|----------|--------| -| Tables don't adapt to mobile | Critical | Campaign list unusable on phones | -| No touch gestures | High | Missed mobile-native patterns | -| Poor table readability | High | Tiny text, requires horizontal scroll | -| Dashboard cards stack poorly | Medium | Suboptimal use of screen space | -| Campaign detail tabs overflow | Medium | 8 tabs on single line | -| Action buttons too small | Medium | Quick actions need better touch targets | -| No bottom sheet for actions | Low | Should use native-feeling bottom sheets | -| Missing safe area handling | Low | Notch/home indicator areas not accounted for | - ---- - -## Design Principles - -1. **Mobile-First Philosophy**: Design for the smallest screen first, then enhance for larger screens -2. **Touch-Optimized**: All interactive elements must be easily tappable (min 44px touch targets) -3. **Context-Aware**: Show relevant information based on screen size and user context -4. **Performance-Conscious**: Minimize re-renders and optimize for mobile browsers -5. **Training-Focused**: Ensure mobile experience doesn't compromise training effectiveness - ---- - -## Technical Architecture - -### Breakpoint System -```css -/* Current breakpoints */ -@media (max-width: 768px) { /* Mobile */ } -@media (min-width: 769px) and (max-width: 1100px) { /* Tablet */ } -@media (min-width: 1101px) { /* Desktop */ } -``` - -### Component Structure -- **MobileNav**: Slide-out drawer for navigation (existing) -- **BottomNav**: Bottom navigation bar for mobile (new) -- **CampaignCard**: Card-based campaign display (new) -- **BottomSheet**: Reusable bottom sheet component (new) -- **SwipeableActions**: Swipe gesture wrapper (new) - ---- - -## Implementation Phases - -### Phase 1: Table to Card Transformation (Critical) -**Duration**: 3-4 hours -**Priority**: Highest - -#### Objectives -- Convert campaign tables to card-based layouts on mobile -- Each campaign becomes a tappable card with key metrics -- Implement swipe-to-reveal actions -- Add expand/collapse for secondary metrics - -#### Technical Details - -**CampaignCard Component** -```tsx -// New component: src/components/AdConsole/mobile/CampaignCard.tsx -interface CampaignCardProps { - campaign: Campaign; - onSelect: (id: string) => void; - onToggleStatus: (id: string) => void; - onDuplicate: (id: string) => void; - onArchive: (id: string) => void; -} - -// Structure: -// - Primary row: Name, Type badge, Status badge -// - Metrics row: Spend, Sales, ROAS -// - Expandable: CPC, Orders, ACOS, Actions -``` - -**Card Layout (Mobile)** -``` -┌─────────────────────────────────┐ -│ ☕ Coffee Filter | Auto | SP │ -│ Status: Enabled | $35/day │ -├─────────────────────────────────┤ -│ Spend: $205.20 | Sales: $684 │ -│ ROAS: 3.3x | ACOS: 30% │ -├─────────────────────────────────┤ -│ [Open] [Pause] [Dup] [Archive] │ -└─────────────────────────────────┘ -``` - -**Swipe Actions** -- Swipe left: Reveal action buttons (Pause, Duplicate, Archive) -- Swipe right: Collapse actions -- Long press: Quick actions menu - -#### Files to Create/Modify -- `src/components/AdConsole/mobile/CampaignCard.tsx` (new) -- `src/components/AdConsole/mobile/SwipeableActions.tsx` (new) -- `src/components/AdConsole/details/ManagerCampaignsTab.tsx` (modify) -- `src/app/globals.css` (add card styles) - ---- - -### Phase 2: Touch Interactions -**Duration**: 2-3 hours -**Priority**: High - -#### Objectives -- Add swipe gestures for campaign actions -- Implement pull-to-refresh on campaign list -- Add long-press for quick actions menu -- Bottom sheet for filter options - -#### Technical Details - -**Pull-to-Refresh** -```tsx -// New hook: src/lib/usePullToRefresh.ts -interface UsePullToRefreshOptions { - onRefresh: () => Promise<void>; - threshold?: number; // Default: 80px -} - -// Implementation: -// - Track touch start/move/end -// - Calculate pull distance -// - Show refresh indicator -// - Trigger refresh on threshold -``` - -**Bottom Sheet Component** -```tsx -// New component: src/components/AdConsole/mobile/BottomSheet.tsx -interface BottomSheetProps { - isOpen: boolean; - onClose: () => void; - title?: string; - children: React.ReactNode; -} - -// Features: -// - Drag to dismiss -// - Backdrop click to close -// - Smooth spring animation -// - Safe area padding -``` - -**Filter Bottom Sheet** -- Replace dropdown filters with bottom sheet on mobile -- Multi-select support for type/status filters -- Clear/reset functionality - -#### Files to Create/Modify -- `src/lib/usePullToRefresh.ts` (new) -- `src/components/AdConsole/mobile/BottomSheet.tsx` (new) -- `src/components/AdConsole/mobile/FilterSheet.tsx` (new) -- `src/components/AdConsole/CampaignManager.tsx` (modify) - ---- - -### Phase 3: Navigation Improvements -**Duration**: 2 hours -**Priority**: Medium - -#### Objectives -- Add bottom navigation bar on mobile -- Improve campaign detail tabs with horizontal scroll + snap -- Add swipe between detail tabs - -#### Technical Details - -**Bottom Navigation Bar** -```tsx -// New component: src/components/AdConsole/mobile/BottomNav.tsx -const NAV_ITEMS = [ - { view: 'dashboard', label: 'Dashboard', icon: '📊' }, - { view: 'campaigns', label: 'Campaigns', icon: '📢' }, - { view: 'create', label: 'Create', icon: '+' }, - { view: 'more', label: 'More', icon: '⋯' }, -]; - -// Structure: -// - Fixed bottom position -// - 4-5 navigation items -// - Active state indicator -// - Safe area padding for iPhone notch -``` - -**Improved Tabs** -```css -/* Horizontal scroll with snap */ -.detail-tabs { - display: flex; - overflow-x: auto; - scroll-snap-type: x mandatory; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; -} - -.detail-tab { - scroll-snap-align: start; - flex-shrink: 0; -} -``` - -**Tab Swipe Navigation** -- Track touch start/end positions -- Calculate swipe direction -- Transition to adjacent tab -- Add visual feedback during swipe - -#### Files to Create/Modify -- `src/components/AdConsole/mobile/BottomNav.tsx` (new) -- `src/components/AdConsole/CampaignDetail.tsx` (modify) -- `src/app/globals.css` (add bottom nav styles) - ---- - -### Phase 4: Layout Optimization -**Duration**: 1-2 hours -**Priority**: Medium - -#### Objectives -- Optimize dashboard grid for narrow screens -- Improve campaign wizard step flow on mobile -- Better spacing and typography scaling -- Safe area padding for notch devices - -#### Technical Details - -**Dashboard Grid Optimization** -```css -/* Mobile-first dashboard */ -@media (max-width: 768px) { - .dashboard-grid { - display: grid; - grid-template-columns: 1fr; - gap: 12px; - } - - .metric-card { - padding: 16px; - } - - .metric-value { - font-size: 24px; - } -} -``` - -**Wizard Step Flow** -- Single column layout on mobile -- Sticky next/back buttons at bottom -- Progress indicator at top -- Collapsible sections for review step - -**Safe Area Handling** -```css -/* iPhone notch support */ -@supports (padding-top: env(safe-area-inset-top)) { - .app-navbar { - padding-top: env(safe-area-inset-top); - } - - .bottom-nav { - padding-bottom: env(safe-area-inset-bottom); - } -} -``` - -#### Files to Modify -- `src/app/globals.css` (optimize mobile layouts) -- `src/components/AdConsole/Dashboard.tsx` (modify) -- `src/components/AdConsole/wizard/CreateCampaignWizard.tsx` (modify) - ---- - -### Phase 5: Performance & Polish -**Duration**: 1-2 hours -**Priority**: Low - -#### Objectives -- Lazy load campaign list items -- Optimize re-renders on scroll -- Add loading skeletons for mobile -- Test on real devices - -#### Technical Details - -**Virtualized List** -```tsx -// Use react-window or similar for large lists -import { FixedSizeList } from 'react-window'; - -// Implement for campaign lists with 50+ items -// Reduces DOM nodes and improves scroll performance -``` - -**Loading Skeletons** -```tsx -// New component: src/components/AdConsole/mobile/SkeletonCard.tsx -// Matches CampaignCard layout -// Shows during initial load and refresh -``` - -**Performance Monitoring** -- Track First Contentful Paint (FCP) -- Monitor Largest Contentful Paint (LCP) -- Measure Time to Interactive (TTI) -- Target: FCP < 1.5s, LCP < 2.5s, TTI < 3.5s - -#### Files to Create/Modify -- `src/components/AdConsole/mobile/SkeletonCard.tsx` (new) -- `src/components/AdConsole/mobile/VirtualizedList.tsx` (new) -- `src/components/AdConsole/details/ManagerCampaignsTab.tsx` (modify) - ---- - -## Testing Strategy - -### Device Testing Matrix -| Device | Browser | Priority | -|--------|---------|----------| -| iPhone 14/15 | Safari | Critical | -| iPhone SE | Safari | High | -| Samsung Galaxy S23 | Chrome | Critical | -| iPad Mini | Safari | Medium | -| Pixel 7 | Chrome | High | - -### Test Scenarios -1. **Campaign Management**: Create, edit, delete, duplicate campaigns -2. **Simulation**: Run 7-day simulation, view results -3. **Navigation**: Switch between views, use bottom nav -4. **Filters**: Apply filters, search campaigns -5. **Touch Gestures**: Swipe actions, pull-to-refresh -6. **Performance**: Scroll smoothness, load times - -### Accessibility Testing -- Screen reader compatibility (VoiceOver, TalkBack) -- Keyboard navigation support -- Color contrast verification (WCAG AA) -- Touch target size verification (min 44px) - ---- - -## Success Metrics - -### Quantitative -- Mobile usability score > 90 (Lighthouse) -- First Contentful Paint < 1.5s on 3G -- Touch target size compliance: 100% -- Zero horizontal scroll on campaign cards - -### Qualitative -- Users can complete training flows on mobile -- Touch interactions feel natural and responsive -- No feature loss compared to desktop -- Positive user feedback on mobile experience - ---- - -## Risk Assessment - -| Risk | Probability | Impact | Mitigation | -|------|-------------|--------|------------| -| Performance degradation on low-end devices | Medium | High | Virtualized lists, lazy loading | -| Touch gesture conflicts with browser | Low | Medium | Careful event handling, test early | -| Feature parity issues | Low | High | Comprehensive testing matrix | -| Training effectiveness reduced | Medium | High | User testing with trainees | - ---- - -## Timeline - -| Phase | Duration | Dependencies | -|-------|----------|--------------| -| Phase 1: Table to Card | 3-4 hours | None | -| Phase 2: Touch Interactions | 2-3 hours | Phase 1 | -| Phase 3: Navigation | 2 hours | Phase 1 | -| Phase 4: Layout Optimization | 1-2 hours | Phase 1 | -| Phase 5: Performance & Polish | 1-2 hours | All phases | - -**Total Estimated Time**: 9-13 hours - ---- - -## Appendix - -### Related Files -- `src/app/globals.css` - Global styles and responsive rules -- `src/components/AdConsole/mobile/MobileNav.tsx` - Existing mobile navigation -- `src/engine/ad-console/core/engine/responsive.ts` - Breakpoint utilities -- `src/lib/useBreakpoint.ts` - Breakpoint hook - -### References -- [Mobile-First Design Principles](https://www.freecodecamp.org/news/mobile-first-design/) -- [Touch Target Size Guidelines](https://www.w3.org/WAI/WCAG21/Target-size.html) -- [iOS Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/) -- [Material Design Touch Targets](https://m3.material.io/foundations/accessible-design/accessibility-basics) diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md deleted file mode 100644 index f691a26..0000000 --- a/docs/SCHEMA.md +++ /dev/null @@ -1,468 +0,0 @@ -# Data Schema Reference - -All types are defined in `src/engine/ad-console/core/types.ts` (core) and `src/engine/ad-console/features/*/types.ts` (features). - ---- - -## Core Types (`core/types.ts`) - -### Enums / Literal Unions - -```ts -type CampaignType = 'SP' | 'SB' | 'SD'; -type CampaignStatus = 'Enabled' | 'Paused' | 'Archived' | 'Draft'; -type TargetingMode = 'Automatic' | 'Manual keyword' | 'Manual product' | 'Keyword' | 'Product' | 'Category' | 'Contextual' | 'Audiences - views remarketing' | 'Audiences - purchases remarketing'; -type BidStrategy = 'Dynamic bids - down only' | 'Dynamic bids - up and down' | 'Fixed bids' | 'Cost per click' | 'Cost per thousand impressions'; -type MatchType = 'Exact' | 'Phrase' | 'Broad'; -type AdFormat = 'Standard' | 'Video' | 'Product collection' | 'Store spotlight' | 'Auto generated' | 'Custom image' | 'Video creative'; -type ConsoleView = 'dashboard' | 'campaigns' | 'create' | 'detail' | 'portfolio' - | 'drills' | 'reports' | 'bulk' | 'trainer' | 'integrity' | 'missions'; -``` - -### Metrics - -```ts -interface Metrics { - impressions: number; // Total ad impressions - clicks: number; // Total ad clicks - spend: number; // Total ad spend in dollars - sales: number; // Total attributed sales in dollars - orders: number; // Total attributed orders -} - -interface DerivedMetrics { - ctr: number; // Click-through rate (0–100%) - cpc: number; // Cost per click in dollars - acos: number; // Advertising cost of sales (0–100%) - roas: number; // Return on ad spend (multiplier) - cvr: number; // Conversion rate (0–100%) -} -``` - -### Campaign - -```ts -interface Campaign { - id: string; // Unique ID (e.g., "C-SP-AUTO-001") - type: CampaignType; // SP, SB, or SD - name: string; // Human-readable name - portfolio: PortfolioType; // Portfolio grouping name - status: CampaignStatus; // Enabled, Paused, Archived, Draft - dailyBudget: number; // Daily budget in dollars - defaultBid: number; // Default CPC bid in dollars - startDate: string; // ISO date string (YYYY-MM-DD) - endDate: string | null; // Optional end date - targetingMode: TargetingMode; // How ads are targeted - adFormat: AdFormat; // Creative format - bidStrategy: BidStrategy; // Bid optimization strategy - placements: { // Placement bid adjustments (%) - top: number; // Top of Search - product: number; // Product pages - rest: number; // Rest of Search - }; - products: string[]; // ASINs in this campaign - creative: Creative | null; // SB/SD creative (null for SP) - creativeStatus?: string; // Approved, Pending, Rejected - creativeIssue?: string; // Rejection reason text - metrics: Metrics; // Campaign-level metrics - adGroups: AdGroup[]; // Child ad groups - targets: Target[]; // Keyword/product targets - searchTerms: SearchTerm[]; // Customer search term data - negatives: Negative[]; // Negative keywords - budgetRules: BudgetRule[]; // Budget rule automation - history: string[]; // Change history log - createdBySimulator?: boolean; // Flag for simulator-created campaigns -} -``` - -### Ad Group - -```ts -interface AdGroup { - id: string; // Unique ID (e.g., "AG-SP-001") - campaignId: string; // Parent campaign ID - name: string; // Ad group name - status: CampaignStatus; // Enabled, Paused, etc. - defaultBid: number; // Default CPC for this ad group - metrics: Metrics; // Ad group-level metrics -} -``` - -### Target (Keyword / Product Target) - -```ts -interface Target { - id: string; // Unique ID (e.g., "T-SP-001") - campaignId: string; // Parent campaign ID - adGroupId: string; // Parent ad group ID - type: string; // 'Keyword' | 'Auto' | 'ASIN' | 'Category' | 'Audience' - value: string; // The keyword text or auto-target label - match: MatchType | string; // Exact, Phrase, Broad, or Auto - bid: number; // CPC bid in dollars - status: CampaignStatus; // Enabled, Paused, Archived - impressions: number; // Target-level impressions - clicks: number; // Target-level clicks - spend: number; // Target-level spend - sales: number; // Target-level sales - orders: number; // Target-level orders -} -``` - -### Search Term - -```ts -interface SearchTerm { - id: string; // Unique ID (e.g., "ST-A-001") - campaignId: string; // Parent campaign ID - adGroupId: string; // Parent ad group ID - term: string; // Customer's search query - target: string; // Matched target value - targetId?: string; // ID of the matched target - recommendation?: string; // 'Add as exact keyword' | 'Negate' | 'Review' - clicks: number; - spend: number; - sales: number; - orders: number; -} -``` - -### Negative Keyword - -```ts -interface Negative { - id: string; // Unique ID (e.g., "NEG-M-001") - campaignId: string; - adGroupId: string; - type: string; // 'Negative exact' | 'Negative phrase' - value: string; // The negative keyword text - sourceSearchTermId?: string; // Original search term that prompted negation -} -``` - -### Budget Rule - -```ts -interface BudgetRule { - id: string; - campaignId: string; - name: string; // Rule name (e.g., "Weekend boost") - type: string; // 'Schedule' | 'Performance' - increase: number; // Budget multiplier (e.g., 1.5 = +50%) - condition: string; // Condition description -} -``` - -### Creative - -```ts -interface Creative { - brandName: string; // Brand display name - logo: string; // Logo identifier - headline: string; // Ad headline text - destination: string; // 'Product detail page' | 'Brand Store' - video: string; // Video asset reference - image: string; // Image asset reference -} -``` - -### Product Catalog - -```ts -interface Product { - asin: string; // Amazon Standard Identification Number - title: string; // Product title - price: number; // Price in dollars - category: string; // Product category - status: string; // 'In stock' | 'Low Inventory' | etc. - rating: number; // Star rating (1-5) - reviews: number; // Review count - image: string; // Emoji or image reference -} -``` - -### Campaign Draft (Wizard) - -```ts -interface CampaignDraft { - type: CampaignType; - name: string; - portfolio: string; - status: CampaignStatus; - dailyBudget: number; - defaultBid: number; - startDate: string; - endDate: string; - targetingMode: TargetingMode; - adFormat: AdFormat; - bidStrategy: BidStrategy; - placements: { top: number; product: number; rest: number }; - products: string[]; - creative: Partial<Creative>; - keywords: string; // One-per-line text input - asinTargets: string; - categoryTargets: string; - audienceTargets: string; -} -``` - -### Application State - -```ts -interface FilterState { - type: 'All' | CampaignType; - status: 'All' | CampaignStatus; - portfolio: 'All' | string; - search: string; -} - -interface AdConsoleState { - version: string; - campaigns: Campaign[]; - filter: FilterState; - selectedCampaignId: string | null; - selectedTab: string; - simulationDays: number; - actionLog: ActionLogEntry[]; - portfolios: string[]; // Known portfolio names (excluding 'All') -} - -interface ActionLogEntry { - timestamp: string; - type: string; - message: string; - tone: 'good' | 'bad' | 'warn'; -} - -### Mobile Menu State - -```ts -type MenuStatus = 'closed' | 'open' | 'closing'; - -interface MobileMenuState { - status: MenuStatus; -} -``` - -### Hook - -```ts -function useBreakpoint(): { - breakpoint: 'mobile' | 'tablet' | 'desktop'; - isMobile: boolean; - isTablet: boolean; - isDesktop: boolean; - isTouch: boolean; -} -``` -``` - ---- - -## Feature Types - -### Drills (`features/drills/types.ts`) - -```ts -type DrillId = - | 'nav-sp-search-term-negative' - | 'nav-sp-placement-controls' - | 'nav-sb-creative-review' - | 'nav-report-request' - | 'nav-sd-audience-path'; - -interface DrillStep { - instruction: string; - targetAction: string; - hint?: string; - skippable?: boolean; -} - -interface DrillDefinition { - id: DrillId; - title: string; - description: string; - adType: string; - difficulty: 'beginner' | 'intermediate' | 'advanced'; - estimatedMinutes: number; - actions: string[]; - steps: DrillStep[]; -} - -interface DrillResult { - drillId: DrillId; - traineeName: string; - completedAt: string; - score: number; // 0-100 - mistakes: number; - skips: number; - totalSteps: number; -} - -interface DrillSession { - drillId: DrillId | null; - currentStep: number; - mistakes: number; - skips: number; - startedAt: string | null; - completed: boolean; - log: string[]; -} -``` - -### Profiles (`features/profiles/types.ts`) - -```ts -interface TraineeProfile { - id: string; - name: string; - createdAt: string; - lastActiveAt: string; -} - -interface ProfileState { - activeProfileId: string; - profiles: TraineeProfile[]; -} -``` - -### Trainer (`features/trainer/types.ts`) - -```ts -interface TrainerNote { - id: string; - timestamp: string; - text: string; -} - -interface ActionGrade { - timestamp: string; - type: string; - message: string; - tone: 'good' | 'bad' | 'warn'; -} - -interface CertificationItem { - id: string; - label: string; - checked: boolean; -} - -interface TrainerState { - notes: TrainerNote[]; - certificationChecklist: CertificationItem[]; -} -``` - -### Bulk (`features/bulk/types.ts`) - -```ts -interface BulkRow { - entity: string; // 'campaign' | 'adGroup' | 'target' | 'negative' | 'budgetRule' - operation: string; // 'update' | 'pause' | 'enable' | 'archive' | 'delete' | 'create' - id?: string; - name?: string; - campaignName?: string; - campaignId?: string; - adGroupId?: string; - field?: string; - value?: string; - [key: string]: string | undefined; // Extensible for additional fields -} - -interface BulkValidationError { - row: number; - field: string; - message: string; -} - -interface BulkPreview { - rows: BulkRow[]; - valid: boolean; - errors: BulkValidationError[]; - summary: string; -} -``` - -### Reports (`features/reports/types.ts`) - -```ts -type ReportType = 'campaign' | 'adGroup' | 'target' | 'searchTerm' | 'placement'; - -interface ReportRequest { - id: string; - type: ReportType; - status: 'pending' | 'completed' | 'failed'; - requestedAt: string; - completedAt?: string; -} - -interface ReportRow { - [key: string]: string | number; -} - -interface Report { - id: string; - type: ReportType; - rows: ReportRow[]; - generatedAt: string; -} -``` - -### Missions (`features/missions/types.ts`) - -```ts -interface Mission { - id: string; - title: string; - description: string; - adType: string; - difficulty: 'beginner' | 'intermediate' | 'advanced'; - steps: MissionStep[]; -} - -interface MissionStep { - instruction: string; - expectedAction: string; - hint: string; -} - -interface MissionSession { - missionId: string | null; - currentStep: number; - score: number; // Starts at 100, -10 per hint - startedAt: string | null; - completed: boolean; - hintsUsed: number; -} - -interface ScenarioDefinition { - id: string; - title: string; - description: string; - difficulty: 'beginner' | 'intermediate' | 'advanced'; - setup: { - campaignId: string; - targetAcos: number; - }; -} -``` - -### Integrity (`features/integrity/types.ts`) - -```ts -interface IntegrityIssue { - id: string; - severity: 'error' | 'warn' | 'info'; - message: string; - entityId: string; - entityType: string; - recommendation: string; -} - -interface IntegrityReport { - score: number; // 0-100, passes at ≥70 - issues: IntegrityIssue[]; - passed: boolean; - lastRun: string | null; -} -``` diff --git a/docs/TECH-SPECS.md b/docs/TECH-SPECS.md deleted file mode 100644 index 5af68ad..0000000 --- a/docs/TECH-SPECS.md +++ /dev/null @@ -1,226 +0,0 @@ -# Technical Specifications - -## Runtime Requirements - -| Requirement | Version | -|------------|---------| -| Node.js | ≥ 18.0 | -| npm | ≥ 9.0 | -| TypeScript | ~5.8 | - -## Dependencies - -### Production - -| Package | Version | Purpose | -|---------|---------|---------| -| `next` | ^16.0.0 | React framework (App Router) | -| `react` | ^19.0.0 | UI library | -| `react-dom` | ^19.0.0 | React DOM renderer | -| `zustand` | ^5.0.0 | State management | -| `@astryxdesign/core` | ^0.1.8 | UI component library (153 components) | -| `@astryxdesign/theme-neutral` | ^0.1.8 | Astryx theme | -| `@phosphor-icons/react` | ^2.1.10 | Icon set | -| `@prisma/client` | ^7.8.0 | Database ORM | -| `@prisma/adapter-neon` | ^7.8.0 | Postgres driver adapter (Neon) | -| `@neondatabase/serverless` | ^1.1.0 | Neon serverless Postgres driver | -| `prisma` | ^7.8.0 | Prisma CLI (also listed as a runtime dep; used by `postinstall`) | -| `next-auth` | ^5.0.0-beta.31 | Authentication | -| `bcryptjs` | ^3.0.3 | Password hashing | -| `motion` | ^12.42.2 | Animation library | - -### Development - -| Package | Version | Purpose | -|---------|---------|---------| -| `@astryxdesign/cli` | ^0.1.8 | Astryx component/token discovery CLI | -| `@types/node` | ^22.0.0 | Node.js type definitions | -| `@types/react` | ^19.0.0 | React type definitions | -| `@types/react-dom` | ^19.0.0 | ReactDOM type definitions | -| `@types/bcryptjs` | ^2.4.6 | bcryptjs type definitions | -| `typescript` | ~5.8.0 | TypeScript compiler | -| `dotenv` | ^17.4.2 | Loads `.env` for `prisma.config.ts` | -| `vitest` | ^4.1.10 | Test runner | -| `@vitest/coverage-v8` | ^4.1.10 | Code coverage | -| `@playwright/test` | ^1.61.1 | E2E testing | -| `@testing-library/react` | ^16.3.2 | React testing utilities | -| `@testing-library/user-event` | ^14.6.1 | User interaction simulation | -| `jsdom` | ^29.1.1 | DOM implementation for tests | - -See `package.json` for the authoritative, exact version list — this table is a point-in-time summary and will drift as dependencies are bumped. - -## TypeScript Configuration - -```json -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["dom", "dom.iterable", "esnext"], - "strict": true, - "module": "esnext", - "moduleResolution": "bundler", - "jsx": "preserve", - "incremental": true, - "paths": { "@/*": ["./src/*"] } - } -} -``` - -Key settings: -- **strict mode**: All strict type-checking options enabled -- **ES2022 target**: Modern JavaScript output -- **Bundler resolution**: Compatible with Next.js bundler -- **Path alias**: `@/*` maps to `./src/*` - -## Next.js Configuration - -```ts -const nextConfig: NextConfig = { - reactStrictMode: true, -}; -``` - -Minimal configuration. No custom webpack, no env files, no middleware. - -## Database Configuration - -### Prisma Schema -```prisma -generator client { - provider = "prisma-client" - output = "../src/generated/prisma" -} - -datasource db { - provider = "postgresql" -} -``` -(The connection itself — `DATABASE_URL` plus the `@prisma/adapter-neon` driver adapter — is wired up in `prisma.config.ts` / `src/lib/prisma.ts`, not the `url` field here; Prisma 7's driver-adapter pattern moved that out of `schema.prisma`.) - -### Environment Variables -```env -# Prisma (Postgres — e.g. from Vercel Storage → Postgres, or Neon directly) -DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require" - -# NextAuth/Auth.js session secret — generate with: openssl rand -base64 32 -AUTH_SECRET="your-secret-key-here" -``` - -### Database Commands -```bash -# Local development -npx prisma migrate dev --name init -npx prisma generate -npx prisma db push - -# Production deployment (CI/deploy pipelines — non-interactive, no schema drift prompts) -npx prisma migrate deploy -``` - -## File Statistics - -*Point-in-time snapshot as of 2026-08-03 (v3.6.0) — expect drift; re-run the `find`/`wc -l` commands below rather than trusting these numbers long-term.* - -| Directory | Files | Total Lines | -|-----------|-------|------------| -| `src/engine/ad-console/core/` (incl. `engine/`, `slices/`) | 25 | ~2,330 | -| `src/engine/ad-console/features/` | 21 | ~1,180 | -| `src/engine/ad-console/` (root: `index.ts`, `store.ts`, `scenarios.ts`, `types.ts`) | 4 | ~180 | -| `src/components/AdConsole/` | 44 | ~3,930 | -| `src/components/` (root) | 3 | ~160 | -| `src/app/` (top-level, incl. `globals.css`) | 5 | ~4,530 | -| `src/lib/` | 7 | ~260 | -| **Total src/** | — | ~20,700 | - -Note: `core/` was originally a 3-file module (`types.ts`, a single `engine.ts`, `scenarios.ts`); it's since been split into `core/engine/` (one file per domain concern — `campaign.ts`, `target.ts`, `adgroup.ts`, `negative.ts`, `budget.ts`, `portfolio.ts`, `draft.ts`, `id.ts`, `metrics.ts`, `responsive.ts`, `search-term-generator.ts`), `core/simulation.ts`, and `core/slices/` (the Zustand-dependent wrappers) — see `CLAUDE.md` for the current breakdown. - -### Selected File Sizes - -| File | Responsibility | -|------|---------------| -| `globals.css` | Design system tokens + responsive styles (largest single file in the repo) | -| `store.ts` | Zustand root store composition | -| `core/types.ts` | Domain interfaces | -| `core/scenarios.ts` | Training data & product catalog | -| `CampaignManager.tsx` | Campaign list + filters | -| `CampaignDetail.tsx` | Single campaign deep-dive | -| `wizard/CreateCampaignWizard.tsx` + `wizard/steps/**` | 6-step, per-ad-type creation flow | -| `MobileNav.tsx` | Mobile/tablet hamburger drawer navigation | -| `auth.ts` | NextAuth configuration | -| `prisma.ts` | Prisma client singleton | - -## Testing Configuration - -### Vitest Config -```ts -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - environment: 'jsdom', - globals: true, - setupFiles: ['./vitest.setup.ts'], - }, -}); -``` - -### Test Commands -```bash -npm test # Run all tests -npm run test:watch # Watch mode -npm run test:e2e # Playwright E2E tests -``` - -## Performance Targets - -| Metric | Target | -|--------|--------| -| First Contentful Paint | < 1.5s | -| Largest Contentful Paint | < 2.5s | -| Time to Interactive | < 3.5s | -| Cumulative Layout Shift | < 0.1 | -| Total Bundle Size | < 500KB | - -## Security Configuration - -### Authentication -- Password hashing: bcrypt (10 salt rounds) -- Session strategy: JWT -- Cookie flags: HTTP-only, Secure, SameSite=Lax - -### Database -- User data isolation via userId foreign key -- Cascade deletes for user data -- Unique constraints on user email and campaign IDs - -### API Routes -- Session validation on all protected routes -- Input validation on all endpoints -- Rate limiting (planned) - -## Deployment - -### Vercel (Recommended) -```bash -npm install -g vercel -vercel -``` - -### Docker -```dockerfile -FROM node:18-alpine -WORKDIR /app -COPY package*.json ./ -RUN npm ci -COPY . . -RUN npx prisma generate -RUN npm run build -EXPOSE 3000 -CMD ["npm", "start"] -``` - -### Environment Variables for Production -```env -DATABASE_URL="postgresql://user:password@host:5432/db" -AUTH_SECRET="strong-random-secret" -``` diff --git a/docs/research-amazon-console-structure.md b/docs/research-amazon-console-structure.md deleted file mode 100644 index 3d9021c..0000000 --- a/docs/research-amazon-console-structure.md +++ /dev/null @@ -1,74 +0,0 @@ -# Research: Amazon Advertising Console — Entity Model & Navigation - -Grounded in the real Amazon Advertising Console (advertising.amazon.com) and -cross-checked against this repo's engine (`src/engine/ad-console/core/types.ts`, -`engine.ts`, `scenarios.ts`). Live scraping of Amazon help pages is blocked by -bot protection, so this reflects the console's stable, well-established structure. - -## Real console entity hierarchy (nesting) - -``` -Account (Training Account) -└── Portfolio (optional grouping; name only in this sim) - └── Campaign (SP | SB | SD) - ├── settings: name, portfolio, start/end date, daily budget, - │ bidding strategy, campaign type, placements - ├── Ad group (1..n) - │ ├── settings: name, status, default bid - │ └── Target / Keyword (1..n) - │ ├── SP manual: keyword + match type (Exact/Phrase/Broad) + bid + status - │ ├── SP auto: auto targets (close/loose/substitutes/complements) - │ └── SD: audiences / products / categories - ├── Search term (SP only; report rows linked to a target) - ├── Negative (negative exact / negative phrase; campaign or ad-group level) - └── Budget rule (scheduled / performance-based budget adjustment) -``` - -## Navigation (real console) - -- Global nav: **Campaign Manager**, **Portfolios**, **Measurement**, **Brands** - (creative assets), **Stores**. -- Campaign Manager → campaign list (filter by type/status/portfolio + date range) - → click campaign → detail with tabs: - - **Ad groups** → click an ad group → its keywords/targets (nested drill-down) - - **Targeting / Keywords** - - **Search terms** (SP) → Negate / Add-as-exact actions - - **Negative keywords** - - **Reach / Placements** (SP: Top of Search / Product pages / Rest of Search) - - **Budget rules** - - **Change history** -- Editing is inline (edit pencil) or via **Edit** buttons; save persists. - -## Metrics cascade - -keyword → ad group → campaign → account -Raw: impressions, clicks, spend, sales, orders, units -Derived: CTR, CPC, ACoS, ROAS, CvR - -## Current repo coverage vs. gaps - -| Capability | Status in repo | -|------------|----------------| -| Global nav (Campaign Manager / Portfolios / Measurement) | Done (reskin PR #9) | -| Dashboard KPI tiles | Done | -| Campaign list + filter | Done | -| Campaign detail tabs (overview/adgroups/targets/searchTerms/negatives/budgetRules/placements/history) | Done (read-mostly) | -| Edit campaign settings (budget, default bid, bid strategy, status) | Done | -| Edit placements | Done | -| Add/remove/bid/pause target (keyword) | Done | -| Add negative / harvest search term | Done | -| **Ad group CRUD (add/rename/status/default-bid/delete)** | **MISSING** | -| **Add target to a chosen ad group** | **MISSING** (always adGroups[0]) | -| **Ad group detail drill-down (nested targets)** | **MISSING** | -| Portfolio management (create/rename/assign) | Partial (name field only) | -| Simulation (7-day) | Done | - -## Conclusion for the build - -To let a student "familiarize navigation, edit campaigns, ad groups, down to -target level" with correct nesting, the repo needs: -1. Engine ad-group operations (TDD) + `addTarget(campaign, adGroupId, ...)`. -2. Store wiring for those operations. -3. Editable Ad groups tab + click-to-drill into an ad group's targets. -4. Add-keyword form that picks the target ad group. -5. This map as living documentation. diff --git a/docs/safety.md b/docs/safety.md deleted file mode 100644 index fa24d3f..0000000 --- a/docs/safety.md +++ /dev/null @@ -1,25 +0,0 @@ -# Safety Policy — Loop Operations - -## Denylist (never edit without human approval) - -- `.env`, `.env.*` — environment secrets -- `prisma/` — database schema and migrations -- `auth/` — authentication configuration -- `next.config.ts` — build configuration -- Any file containing secrets, API keys, or credentials - -## Auto-merge policy - -- Only markdown files (`docs/**`, `**/*.md`) may be auto-merged -- All other changes require human review via draft PR - -## Escalation - -- After 3 failed fix attempts, escalate to human -- If loop detects sensitive path changes, pause and notify -- Use `loop-pause-all` to halt all automated operations - -## Tool scopes - -- Skills may only use tools documented in their SKILL.md frontmatter -- No external API calls without explicit human approval diff --git a/e2e/campaign-detail.spec.ts b/e2e/campaign-detail.spec.ts deleted file mode 100644 index 855b9ad..0000000 --- a/e2e/campaign-detail.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Campaign Detail', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - // Navigate to campaign manager - await page.click('.nav-section:has-text("Campaign Manager")'); - // Click the first campaign name to open detail - await page.locator('.row-link').first().click(); - await expect(page.locator('.detail-header h1')).toBeVisible(); - }); - - test('shows campaign detail with header and tabs', async ({ page }) => { - await expect(page.locator('.breadcrumb')).toContainText('Campaign manager'); - await expect(page.locator('.detail-meta')).toBeVisible(); - await expect(page.locator('.detail-actions button:has-text("Pause")')).toBeVisible(); - await expect(page.locator('.detail-actions button:has-text("Duplicate")')).toBeVisible(); - await expect(page.locator('.detail-actions button:has-text("Archive")')).toBeVisible(); - await expect(page.locator('.detail-actions button:has-text("Run 7-day sim")')).toBeVisible(); - // Default tab is Ad groups - await expect(page.locator('.tab.active')).toContainText('Ad groups'); - }); - - test('tab switching works in detail view', async ({ page }) => { - // Default active tab is Ad groups - await expect(page.locator('.tab.active')).toContainText('Ad groups'); - - // Switch to Overview - await page.click('.tab:has-text("Overview")'); - await expect(page.locator('.tab.active')).toContainText('Overview'); - - // Switch to Targeting - await page.click('.tab:has-text("Targeting")'); - await expect(page.locator('.tab.active')).toContainText('Targeting'); - - // Switch back to Ad groups - await page.click('.tab:has-text("Ad groups")'); - await expect(page.locator('.tab.active')).toContainText('Ad groups'); - }); - - test('Overview tab shows campaign settings', async ({ page }) => { - // Default tab is Ad groups, click into Overview - await page.click('.tab:has-text("Overview")'); - await expect(page.locator('.tab.active')).toContainText('Overview'); - await expect(page.locator('.card-title:has-text("Campaign settings")')).toBeVisible(); - await expect(page.locator('.card-title:has-text("Products")')).toBeVisible(); - }); - - test('duplicate campaign creates a copy', async ({ page }) => { - const originalName = await page.locator('.detail-header h1').textContent(); - await page.click('button:has-text("Duplicate")'); - - // Wait a beat for the store update - await page.waitForTimeout(300); - await page.click('.breadcrumb button:has-text("Campaign manager")'); - await expect(page.locator('h1')).toContainText('Campaign manager'); - - // The copied campaign should be in the list - await expect(page.locator(`text="${originalName} (copy)"`).first()).toBeVisible(); - }); - - test('Run 7-day sim works from detail view', async ({ page }) => { - await page.click('button:has-text("Run 7-day sim")'); - await page.waitForTimeout(300); - // Should still be on detail view - await expect(page.locator('.detail-header h1')).toBeVisible(); - }); - - test('toggle pause/enable works', async ({ page }) => { - const pauseBtn = page.locator('button:has-text("Pause"), button:has-text("Enable")').first(); - const currentText = await pauseBtn.textContent(); - await pauseBtn.click(); - await page.waitForTimeout(300); - - // Button text should have changed - const expected = currentText === 'Pause' ? 'Enable' : 'Pause'; - await expect(page.locator('.detail-actions button:has-text("Enable")').first()).toBeVisible({ timeout: 3000 }); - }); - - test('Back button returns to campaign manager', async ({ page }) => { - await page.click('button:has-text("Back")'); - await expect(page.locator('h1')).toContainText('Campaign manager'); - }); - - test('budget rules tab shows add form', async ({ page }) => { - await page.click('.tab:has-text("Budget rules")'); - await expect(page.locator('.tab.active')).toContainText('Budget rules'); - await expect(page.locator('button:has-text("Add rule")')).toBeVisible(); - }); - - test('change history tab shows event log', async ({ page }) => { - await page.click('.tab:has-text("Change history")'); - await expect(page.locator('.tab.active')).toContainText('Change history'); - // Should have log entries (table rows in the history tab) - const historyRows = await page.locator('.table-wrap tbody tr').count(); - expect(historyRows).toBeGreaterThan(0); - }); - - test('placements tab visible for SP campaigns', async ({ page }) => { - // SP campaign should have Placements tab - await expect(page.locator('.tab:has-text("Placements")')).toBeVisible(); - await page.click('.tab:has-text("Placements")'); - await expect(page.locator('.tab.active')).toContainText('Placements'); - }); -}); diff --git a/e2e/campaign-manager.spec.ts b/e2e/campaign-manager.spec.ts deleted file mode 100644 index 1d521ab..0000000 --- a/e2e/campaign-manager.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Campaign Manager', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - // Navigate to campaigns view via topbar - await page.click('.nav-section:has-text("Campaign Manager")'); - }); - - test('loads with campaign table and tabs', async ({ page }) => { - await expect(page.locator('h1')).toContainText('Campaign manager'); - await expect(page.locator('.tabs .tab')).toHaveCount(5); - await expect(page.locator('table')).toBeVisible(); - }); - - test('shows filter toolbar', async ({ page }) => { - await expect(page.locator('.toolbar')).toBeVisible(); - await expect(page.locator('.toolbar .search input')).toBeVisible(); - await expect(page.locator('.toolbar .select')).toHaveCount(3); - }); - - test('search filters campaigns', async ({ page }) => { - const rows = await page.locator('table tbody tr').count(); - await page.fill('.toolbar .search input', 'SP Auto'); - const filteredRows = await page.locator('table tbody tr').count(); - expect(filteredRows).toBeLessThanOrEqual(rows); - }); - - test('type filter works', async ({ page }) => { - await page.selectOption('.toolbar .select:first-of-type', 'SP'); - const rows = await page.locator('table tbody tr').count(); - expect(rows).toBeGreaterThan(0); - }); - - test('status filter works', async ({ page }) => { - await page.selectOption('.toolbar .select:nth-of-type(2)', 'Enabled'); - const rows = await page.locator('table tbody tr').count(); - expect(rows).toBeGreaterThan(0); - }); - - test('tab switching works', async ({ page }) => { - // Default is campaigns tab - await expect(page.locator('.tab.active')).toContainText('Campaigns'); - - // Switch to ad groups - await page.click('.tab:has-text("Ad groups")'); - await expect(page.locator('.tab.active')).toContainText('Ad groups'); - - // Switch to targets - await page.click('.tab:has-text("Targeting")'); - await expect(page.locator('.tab.active')).toContainText('Targeting'); - - // Switch to search terms - await page.click('.tab:has-text("Search terms")'); - await expect(page.locator('.tab.active')).toContainText('Search terms'); - - // Switch to negatives - await page.click('.tab:has-text("Negatives")'); - await expect(page.locator('.tab.active')).toContainText('Negatives'); - }); - - test('Run 7-day sim button triggers simulation', async ({ page }) => { - await page.click('button:has-text("Run 7-day sim")'); - await page.waitForTimeout(500); - // Should still be on campaign manager - await expect(page.locator('h1')).toContainText('Campaign manager'); - }); - - test('Create campaign button navigates to wizard', async ({ page }) => { - await page.click('button:has-text("Create campaign")'); - await expect(page.locator('h1')).toContainText('Create campaign'); - }); - - test('Reset button clears filters', async ({ page }) => { - await page.fill('.toolbar .search input', 'test'); - await page.selectOption('.toolbar .select:first-of-type', 'SP'); - await page.click('.toolbar button:has-text("Reset")'); - await expect(page.locator('.toolbar .search input')).toHaveValue(''); - }); -}); diff --git a/e2e/campaign-wizard.spec.ts b/e2e/campaign-wizard.spec.ts deleted file mode 100644 index e08dafd..0000000 --- a/e2e/campaign-wizard.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Create Campaign Wizard', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - await page.click('button:has-text("Create campaign")'); - }); - - test('shows 6-step wizard with step 1 active', async ({ page }) => { - await expect(page.locator('.wizard .step')).toHaveCount(6); - await expect(page.locator('.wizard .step.active')).toContainText('1'); - await expect(page.locator('.wizard-panel')).toContainText('Choose campaign type'); - }); - - test('step 1: shows SP, SB, SD choices', async ({ page }) => { - await expect(page.locator('.choice')).toHaveCount(3); - await expect(page.locator('.choice')).toContainText(['Sponsored Products', 'Sponsored Brands', 'Sponsored Display']); - }); - - test('step 1: selecting SP highlights it', async ({ page }) => { - await page.click('.choice:has-text("Sponsored Products")'); - await expect(page.locator('.choice.active')).toContainText('Sponsored Products'); - }); - - test('full wizard flow: create SP campaign', async ({ page }) => { - // Step 1: Select SP - await page.click('.choice:has-text("Sponsored Products")'); - await page.click('button:has-text("Next")'); - - // Step 2: Basics - await expect(page.locator('.wizard-panel')).toContainText('Campaign basics'); - await page.fill('input[placeholder*="SP | Manual"]', 'E2E Test Campaign'); - await page.fill('input[type="number"][min="1"]', '25'); - await page.click('button:has-text("Next")'); - - // Step 3: Products - await expect(page.locator('.wizard-panel')).toContainText('Products & creative'); - await page.click('button:has-text("Next")'); - - // Step 4: Targeting - await expect(page.locator('.wizard-panel')).toContainText('Targeting'); - await page.click('button:has-text("Next")'); - - // Step 5: Bidding - await expect(page.locator('.wizard-panel')).toContainText('Bidding'); - await page.click('button:has-text("Next")'); - - // Step 6: Review - await expect(page.locator('.wizard-panel')).toContainText('Review'); - await expect(page.locator('.review-row:has-text("Name")')).toContainText('E2E Test Campaign'); - }); - - test('Back button returns to previous step', async ({ page }) => { - await page.click('.choice:has-text("Sponsored Products")'); - await page.click('button:has-text("Next")'); - await expect(page.locator('.wizard-panel')).toContainText('Campaign basics'); - - await page.click('button:text-is("Back")'); - await expect(page.locator('.wizard-panel')).toContainText('Choose campaign type'); - }); - - test('Reset draft clears the form', async ({ page }) => { - await page.click('.choice:has-text("Sponsored Products")'); - await page.click('button:has-text("Next")'); - await page.fill('input[placeholder*="SP | Manual"]', 'Test'); - await page.click('button:has-text("Reset draft")'); - - // Should return to step 1 - await expect(page.locator('.wizard-panel')).toContainText('Choose campaign type'); - }); - - test('Back to campaigns returns to campaign manager', async ({ page }) => { - await page.click('button:has-text("Back to campaigns")'); - await expect(page.locator('h1')).toContainText('Campaign manager'); - }); - - test('full launch flow: create and launch SP campaign', async ({ page }) => { - // Step 1: Select SP - await page.click('.choice:has-text("Sponsored Products")'); - await page.click('button:has-text("Next")'); - - // Step 2: Basics - await page.fill('input[placeholder*="SP | Manual"]', 'E2E Launch Test'); - await page.click('button:has-text("Next")'); - - // Step 3: Products - await page.click('button:has-text("Next")'); - - // Step 4: Targeting - await page.click('button:has-text("Next")'); - - // Step 5: Bidding - await page.click('button:has-text("Next")'); - - // Step 6: Review — verify summary - await expect(page.locator('.wizard-panel')).toContainText('Review'); - await expect(page.locator('.review-row:has-text("Name")')).toContainText('E2E Launch Test'); - - // Launch campaign - await page.click('button:has-text("Launch campaign")'); - - // Should navigate to campaign detail view - await expect(page.locator('.breadcrumb')).toContainText('E2E Launch Test'); - await expect(page.locator('.detail-header h1')).toContainText('E2E Launch Test'); - await expect(page.locator('.detail-meta')).toContainText('SP'); - await expect(page.locator('.detail-actions button:has-text("Pause")')).toBeVisible(); - await expect(page.locator('.detail-actions button:has-text("Duplicate")')).toBeVisible(); - await expect(page.locator('.detail-actions button:has-text("Archive")')).toBeVisible(); - - // Detail tabs should be present (SP has 8 tabs including Placements) - const tabCount = await page.locator('.tabs .tab').count(); - expect(tabCount).toBeGreaterThanOrEqual(7); - }); -}); diff --git a/e2e/dashboard-mobile.spec.ts b/e2e/dashboard-mobile.spec.ts deleted file mode 100644 index 0d4eb2b..0000000 --- a/e2e/dashboard-mobile.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Mobile-viewport e2e checks for the Amazon Ads Dashboard. - * - * Per MOBILE_REDESIGN_PLAN Phase 1 + Phase 4: - * - At <768px the dashboard must render CampaignCard articles, not a <table> - * - KPI grid must collapse to 1 column at <480px - * - No horizontal scroll on the campaign list region - * - Touch targets (44px minimum) on the action buttons when expanded - * - The .skip-link skip-to-main-content link remains functional at all widths - * - * These tests run against the mobile-chromium project (iPhone SE viewport - * 375x800) to mirror the at-risk device class in MOBILE_REDESIGN_PLAN. - */ -import { test, expect } from '@playwright/test'; - -test.describe('Dashboard @ mobile (375px)', () => { - // First compile in the dev server is slow under cold cache. Bump the - // per-test timeout to 90s so the first test does not race cold-start. - test.setTimeout(90000); - test('renders CampaignCard articles instead of a table', async ({ page }) => { - await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }); - await expect(page.locator('h1')).toContainText('Advertising'); - await expect(page.locator('.campaign-card-list')).toBeVisible(); - const cards = page.locator('.campaign-card'); - expect(await cards.count()).toBeGreaterThan(0); - await expect(page.locator('.app-content table')).toHaveCount(0); - }); - - test('every campaign card shows the campaign name and status', async ({ page }) => { - await page.goto('/dashboard'); - const cards = page.locator('.campaign-card'); - // Wait for the first card to mount before counting (auto-wait on - // .count() does not exist; await expect.poll is the explicit pattern). - await expect(cards.first()).toBeVisible(); - const count = await cards.count(); - expect(count).toBeGreaterThan(0); - for (let i = 0; i < count; i++) { - const card = cards.nth(i); - await expect(card.locator('.campaign-card__name')).toBeVisible(); - // Status is rendered as a pill ('pill green' / 'pill orange' / etc.). - // Match by accessible text content instead of class. - await expect(card.getByText(/^(Enabled|Paused|Archived|Draft)$/)).toBeVisible(); - } - }); - - test('shows the primary metrics on each card (Spend, Sales, ROAS)', async ({ page }) => { - await page.goto('/dashboard'); - const firstCard = page.locator('.campaign-card').first(); - await expect(firstCard).toContainText('Spend'); - await expect(firstCard).toContainText('Sales'); - await expect(firstCard).toContainText('ROAS'); - }); - - test('expand toggle reveals ACOS, CPC, Orders + Pause/Archive', async ({ page }) => { - await page.goto('/dashboard'); - const firstCard = page.locator('.campaign-card').first(); - const toggle = firstCard.locator('.campaign-card__toggle'); - await expect(toggle).toHaveAttribute('aria-expanded', 'false'); - await toggle.click(); - await expect(toggle).toHaveAttribute('aria-expanded', 'true'); - await expect(firstCard.locator('text=CPC')).toBeVisible(); - await expect(firstCard.locator('text=ACOS')).toBeVisible(); - await expect(firstCard.locator('text=Orders')).toBeVisible(); - await expect(firstCard.locator('button:has-text("Pause")')).toBeVisible(); - await expect(firstCard.locator('button:has-text("Archive")')).toBeVisible(); - }); - - test('Pause and Archive touch targets are at least 44px tall', async ({ page }) => { - await page.goto('/dashboard'); - const firstCard = page.locator('.campaign-card').first(); - await firstCard.locator('.campaign-card__toggle').click(); - const pauseBtn = firstCard.locator('button:has-text("Pause")'); - const pauseBox = await pauseBtn.boundingBox(); - expect(pauseBox).not.toBeNull(); - expect(pauseBox!.height).toBeGreaterThanOrEqual(44); - const archiveBtn = firstCard.locator('button:has-text("Archive")'); - const archiveBox = await archiveBtn.boundingBox(); - expect(archiveBox).not.toBeNull(); - expect(archiveBox!.height).toBeGreaterThanOrEqual(44); - }); - - test('no horizontal scroll on the campaign list region', async ({ page }) => { - await page.goto('/dashboard'); - const cardList = page.locator('.campaign-card-list').first(); - await expect(cardList).toBeVisible(); - const box = await cardList.boundingBox(); - expect(box).not.toBeNull(); - expect(box!.width).toBeLessThanOrEqual(375); - }); - - test('skip-to-main-content link remains functional', async ({ page }) => { - await page.goto('/dashboard'); - const skip = page.locator('a.skip-link'); - await expect(skip).toHaveAttribute('href', '#main-content'); - const main = page.locator('main#main-content'); - await expect(main).toBeVisible(); - }); - - test('KPI grid is single-column at 375px', async ({ page }) => { - await page.goto('/dashboard'); - const grid = page.locator('.kpi-grid'); - await expect(grid).toBeVisible(); - const cols = await grid.evaluate((el) => getComputedStyle(el).gridTemplateColumns); - const colCount = cols.trim().split(/\s+/).length; - expect(colCount).toBe(1); - }); -}); diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts deleted file mode 100644 index c927892..0000000 --- a/e2e/dashboard.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Dashboard', () => { - test('loads with KPI tiles and campaign table', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('h1')).toContainText('Advertising Dashboard'); - await expect(page.locator('.kpi-grid .kpi-tile')).toHaveCount(9); - await expect(page.locator('table')).toBeVisible(); - }); - - test('shows campaign count in table header', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('.card-title').first()).toContainText('Campaigns'); - }); - - test('shows operator alerts', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('.insight-list')).toBeVisible(); - await expect(page.locator('.insight')).toHaveCount(3); - }); - - test('shows training coverage pills', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('.pill-row .pill')).toHaveCount(7); - }); - - test('Create campaign button navigates to wizard', async ({ page }) => { - await page.goto('/'); - await page.click('button:has-text("Create campaign")'); - await expect(page.locator('h1')).toContainText('Create campaign'); - await expect(page.locator('.wizard')).toBeVisible(); - }); -}); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts deleted file mode 100644 index 1d511dc..0000000 --- a/e2e/navigation.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Navigation', () => { - test('topbar navigation switches views', async ({ page }) => { - await page.goto('/'); - - // Dashboard is default - await expect(page.locator('h1')).toContainText('Advertising Dashboard'); - - // Navigate to campaigns via topbar - await page.click('.nav-section:has-text("Campaign Manager")'); - await expect(page.locator('h1')).toContainText('Campaign manager'); - - // Navigate to portfolios via topbar - await page.click('.nav-section:has-text("Portfolios")'); - await expect(page.locator('h1')).toContainText('Portfolio'); - - // Navigate back to dashboard via topbar - await page.click('.nav-section:has-text("Measurement")'); - await expect(page.locator('h1')).toContainText('Advertising Dashboard'); - }); - - test('topbar shows global nav sections', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('.nav-section')).toHaveCount(3); - await expect(page.locator('.nav-section')).toContainText(['Campaign Manager', 'Portfolios', 'Measurement']); - }); - - test('sidebar shows section items on campaigns view', async ({ page }) => { - await page.goto('/'); - await page.click('.nav-section:has-text("Campaign Manager")'); - // Sidebar should show campaign-related items - await expect(page.locator('.sidebar-item')).toHaveCount(7); // 5 campaign items + Run 7-day sim + Reset sandbox - }); - - test('sidebar has simulation controls', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('.sidebar-item:has-text("Run 7-day sim")')).toBeVisible(); - await expect(page.locator('.sidebar-item:has-text("Reset sandbox")')).toBeVisible(); - }); - - test('dashboard has Create campaign button', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('.page-title button:has-text("Create campaign")')).toBeVisible(); - }); -}); diff --git a/e2e/simulation.spec.ts b/e2e/simulation.spec.ts deleted file mode 100644 index a51a042..0000000 --- a/e2e/simulation.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Simulation', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - }); - - test('Run 7-day sim updates dashboard metrics', async ({ page }) => { - // Get initial metrics - const initialTiles = await page.locator('.kpi-grid .kpi-tile .value').allTextContents(); - - // Run simulation - await page.click('.sidebar-item:has-text("Run 7-day sim")'); - await page.waitForTimeout(500); - - // Metrics should be present (may or may not change depending on initial state) - const afterTiles = await page.locator('.kpi-grid .kpi-tile .value').allTextContents(); - expect(afterTiles).toHaveLength(9); - }); - - test('Run 7-day sim from campaign manager', async ({ page }) => { - await page.click('.nav-section:has-text("Campaign Manager")'); - await page.click('button:has-text("Run 7-day sim")'); - await page.waitForTimeout(500); - - // Should still be on campaign manager - await expect(page.locator('h1')).toContainText('Campaign manager'); - }); - - test('Reset sandbox clears all data', async ({ page }) => { - // Accept the confirmation dialog - page.on('dialog', (dialog) => dialog.accept()); - - await page.click('.sidebar-item:has-text("Reset sandbox")'); - await page.waitForTimeout(500); - - // Dashboard should still be visible with fresh data - await expect(page.locator('h1')).toContainText('Advertising Dashboard'); - }); -}); diff --git a/e2e/user-journey.spec.ts b/e2e/user-journey.spec.ts deleted file mode 100644 index 87a3512..0000000 --- a/e2e/user-journey.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('User Journey — Full simulation from root', () => { - test('simulate a complete user session: browse, create, inspect, simulate', async ({ page }) => { - // ── STEP 1: Landing on Dashboard ────────────────────────────────── - await page.goto('/'); - await expect(page.locator('h1')).toContainText('Advertising Dashboard'); - await expect(page.locator('.kpi-grid .kpi-tile')).toHaveCount(9); - await expect(page.locator('table')).toBeVisible(); - await expect(page.locator('.insight-list .insight')).toHaveCount(3); - - // ── STEP 2: Navigate to Campaign Manager ────────────────────────── - await page.click('.nav-section:has-text("Campaign Manager")'); - await expect(page.locator('h1')).toContainText('Campaign manager'); - await expect(page.locator('.toolbar')).toBeVisible(); - await expect(page.locator('.toolbar .select')).toHaveCount(3); - - // ── STEP 3: Filter campaigns by type ─────────────────────────────── - await page.selectOption('.toolbar .select:first-of-type', 'SP'); - let rows = await page.locator('table tbody tr').count(); - expect(rows).toBeGreaterThan(0); - - // ── STEP 4: Click into a campaign detail ────────────────────────── - await page.locator('.row-link').first().click(); - await expect(page.locator('.detail-header h1')).toBeVisible(); - const campaignName = await page.locator('.detail-header h1').textContent(); - - // ── STEP 5: Browse tabs ─────────────────────────────────────────── - // Start at Ad groups (default) - await expect(page.locator('.tab.active')).toContainText('Ad groups'); - - // Browse Overview - await page.click('.tab:has-text("Overview")'); - await expect(page.locator('.card-title:has-text("Campaign settings")')).toBeVisible(); - - // Browse Targeting - await page.click('.tab:has-text("Targeting")'); - await expect(page.locator('.tab.active')).toContainText('Targeting'); - - // Browse Negatives - await page.click('.tab:has-text("Negatives")'); - await expect(page.locator('.tab.active')).toContainText('Negatives'); - - // Browse Budget rules - await page.click('.tab:has-text("Budget rules")'); - await expect(page.locator('button:has-text("Add rule")')).toBeVisible(); - - // Browse Change history - await page.click('.tab:has-text("Change history")'); - await expect(page.locator('.tab.active')).toContainText('Change history'); - const historyRows = await page.locator('.table-wrap tbody tr').count(); - expect(historyRows).toBeGreaterThanOrEqual(1); - - // ── STEP 6: Run simulation on this campaign ─────────────────────── - await page.click('button:has-text("Run 7-day sim")'); - await page.waitForTimeout(500); - - // Metrics should now have some data - await expect(page.locator('.detail-header h1')).toContainText(campaignName!); - - // ── STEP 7: Duplicate the campaign ──────────────────────────────── - await page.click('button:has-text("Duplicate")'); - await page.waitForTimeout(300); - - // ── STEP 8: Go back to campaign manager ─────────────────────────── - await page.click('.breadcrumb button:has-text("Campaign manager")'); - await expect(page.locator('h1')).toContainText('Campaign manager'); - // Switch to Campaigns tab (selectedTab may be from detail view) - await page.click('.tab:has-text("Campaigns")'); - await page.waitForTimeout(200); - - // ── STEP 9: Verify duplicate exists ─────────────────────────────── - // The duplicate (with (copy) suffix) should appear in the list - await expect(page.locator('table tbody tr').first()).toBeVisible(); - const allCampaignNames = await page.locator('.row-link').allTextContents(); - expect(allCampaignNames.some((n) => n.includes('(copy)'))).toBeTruthy(); - - // ── STEP 10: Navigate to Portfolios ─────────────────────────────── - await page.click('.nav-section:has-text("Portfolios")'); - await expect(page.locator('h1')).toContainText('Portfolio'); - - // ── STEP 11: Create a new campaign ──────────────────────────────── - await page.click('button:has-text("Create campaign")'); - await expect(page.locator('h1')).toContainText('Create campaign'); - - // Step 1: Select SP - await page.click('.choice:has-text("Sponsored Products")'); - await page.click('button:has-text("Next")'); - - // Step 2: Fill basics - await page.fill('input[placeholder*="SP | Manual"]', 'Journey Test Campaign'); - await page.click('button:has-text("Next")'); - - // Step 3-5: Skip through with defaults - await page.click('button:has-text("Next")'); - await page.click('button:has-text("Next")'); - await page.click('button:has-text("Next")'); - - // Step 6: Review - await expect(page.locator('.review-row:has-text("Name")')).toContainText('Journey Test Campaign'); - await page.click('button:has-text("Launch campaign")'); - - // ── STEP 12: Verify new campaign detail ────────────────────────── - await expect(page.locator('.breadcrumb')).toContainText('Journey Test Campaign'); - await expect(page.locator('.detail-header h1')).toContainText('Journey Test Campaign'); - - // ── STEP 13: Run simulation and verify metrics update ───────────── - await page.click('button:has-text("Run 7-day sim")'); - await page.waitForTimeout(500); - - // ── STEP 14: Toggle campaign status ─────────────────────────────── - const pauseBtn = page.locator('.detail-actions button:has-text("Pause")'); - if (await pauseBtn.isVisible()) { - await pauseBtn.click(); - await page.waitForTimeout(300); - await expect(page.locator('.detail-actions button:has-text("Enable")')).toBeVisible(); - } - - // ── STEP 15: Navigate back to Dashboard via Measurement ─────────── - await page.click('.nav-section:has-text("Measurement")'); - await expect(page.locator('h1')).toContainText('Advertising Dashboard'); - - // ── STEP 16: Verify KPI metrics updated from simulation ─────────── - const kpiValueTexts = await page.locator('.kpi-grid .kpi-tile .value').allTextContents(); - expect(kpiValueTexts).toHaveLength(9); - - // ── STEP 17: Verify campaign count updated ──────────────────────── - await expect(page.locator('.card-title').first()).toContainText('Campaigns'); - }); -}); diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/gate.yaml b/gate.yaml deleted file mode 100644 index 9e6bba4..0000000 --- a/gate.yaml +++ /dev/null @@ -1,18 +0,0 @@ -version: 1 - -denylist: - - ".env" - - ".env.*" - - "**/secrets/**" - - "**/credentials/**" - - "**/*_key*" - - "**/*_secret*" - - "prisma/**" - - "auth/**" - - "next.config.ts" - -maxFiles: 10 - -autoMergeAllowlist: - - "docs/**" - - "**/*.md" diff --git a/legacy/HANDOFF.md b/legacy/HANDOFF.md deleted file mode 100644 index 56d332f..0000000 --- a/legacy/HANDOFF.md +++ /dev/null @@ -1,391 +0,0 @@ -# Amazon Ad Console — Handoff Document for AI Coder - -## Project Overview -Amazon Advertising Console simulator (SP, SB, SD) with Next.js 14, React 18, TypeScript, Zustand, Vitest. - -**Stack**: Next.js 14, React 19, TypeScript, Zustand, Vitest, Playwright - ---- - -## Current Architecture (SOLID Assessment) - -### ✅ Well-Structured (Engine Layer) -``` -src/engine/ad-console/ -├── core/ -│ ├── types.ts # Domain types (S) -│ ├── simulation.ts # Pure simulation (S, O) -│ ├── engine/ # Focused modules (S, D) -│ │ ├── id.ts, metrics.ts, campaign.ts, target.ts, adgroup.ts -│ │ ├── negative.ts, budget.ts, portfolio.ts, draft.ts -│ │ ├── responsive.ts, index.ts (barrel) -│ ├── slices/ # Zustand slice factories (S, D) -│ ├── __tests__/ # 312 passing tests -│ └── scenarios.ts # Default campaigns -├── features/ # Feature slices (S, O, L) -│ ├── bulk/, drills/, integrity/, missions/, profiles/, reports/, trainer/ -└── store.ts # Composed store (D) -``` - -### ❌ SOLID Violations (Component Layer) -| Component | Lines | Violations | -|-----------|-------|------------| -| `CreateCampaignWizard.tsx` | 600+ | SRP (6 steps in one), no tests | -| `CampaignManager.tsx` | 350+ | SRP (5 tabs), no tests | -| `CampaignDetail.tsx` | 500+ | SRP (8 tabs), no tests | -| `SearchTermsTab.tsx` | 100+ | No tests | -| `NegativesTab.tsx` | 80+ | No tests | - ---- - -## Campaign Creation Flow — Current State - -### SP (Sponsored Products) — 6 Steps ✅ Mostly Complete -| Step | Fields | -|------|--------| -| 1. Ad Type | SP/SB/SD | -| 2. Basics | Name, Portfolio, Status, Daily Budget, Dates | -| 3. Products | Multi-select from catalog (5 products) | -| 4. Targeting | Mode: Auto / Manual Keyword / Manual Product<br>Manual KW: exact/phrase/broad textareas (one per line)<br>Manual Product: ASIN targets, Category targets<br>Bid Strategy: Dynamic down / up&down / Fixed | -| 5. Bidding | Default bid, Placement % (Top, Product, Rest) | -| 6. Review | Summary + Launch | - -**GAP**: Match types on keyword addition (Step 4) — textareas determine match type, but "Add keyword" button in CampaignDetail uses dropdown. Simulation must generate **distinct search terms per match type**. - -### SB (Sponsored Brands) — 6 Steps ⚠️ Missing Flows -| Missing | Details | -|---------|---------| -| Store Spotlight URL validation | Input exists, no validation | -| Audience lookback window | In draft type, not in wizard UI | -| Category targeting picker | Parsed but no UI | -| Search terms simulation | SB campaigns don't generate search terms | - -### SD (Sponsored Display) — 6 Steps ⚠️ Missing Flows -| Missing | Details | -|---------|---------| -| Audience lookback window | In draft type, not in wizard UI (7/14/30/60/90 days) | -| Contextual category picker | No UI for category selection | -| Search terms | SD doesn't have search terms (by design - uses audiences/contextual) | - ---- - -## Metrics Requirements — ALL Views Must Show - -| View | Current | Required (8 metrics) | -|------|---------|---------------------| -| Campaigns tab | Impr, Clicks, CPC, Spend, ACOS, ROAS | **+ Orders, Sales** | -| Ad Groups tab | ✅ All 8 | ✅ Complete | -| Targets tab | ✅ All 8 | ✅ Complete | -| Search Terms tab | Clicks, CPC, Spend, Sales, Orders, ACOS, ROAS | **+ Impressions** | -| Campaign Detail → Overview | Partial | **All 8** | - -**Required 8**: Impressions, Clicks, CPC, Spend, Sales, Orders, ACOS, ROAS - ---- - -## Negation System — Current & Required - -### Current Implementation (`engine/negative.ts`) -```typescript -isFilteredByNegative(term, negatives): - - Negative exact: term === negative - - Negative phrase: term.includes(negative) // ✅ "plastic" blocks "plastic cup", "red plastic", etc. -``` - -### SearchTermsTab.tsx — Already Filters -```typescript -visibleSearchTerms = c.searchTerms.filter(st => !isFilteredByNegative(st.term, c.negatives)) -``` - -### ⚠️ Gap: CampaignManager "Search Terms" Tab -The CampaignManager's `renderSearchTerms()` does NOT filter by negatives — must fix. - -### User Requirement (Already Met in Engine) -> "a word added as negative phrase should prevent that word or anything that contains that word or phrase from showing up in search terms tab" -**Status**: ✅ Implemented in engine, needs verification in CampaignManager search terms tab. - ---- - -## Simulation — Search Term Generation by Match Type - -### Current (Hardcoded in `simulation.ts`) -```typescript -const matchGens = { - Exact: kw => [kw, kw + 's' or singular], - Phrase: kw => ['organic ' + kw, 'best ' + kw], - Broad: kw => ['cheap ' + kw, kw + ' accessories', kw + ' deals'], -} -``` - -### Required: Extensible Generator Pattern (Open/Closed) -Create `src/engine/ad-console/core/engine/search-term-generator.ts`: -```typescript -interface SearchTermGenerator { - generate(keyword: string): string[]; - getMatchType(): MatchType; -} - -class ExactMatchGenerator implements SearchTermGenerator { ... } -class PhraseMatchGenerator implements SearchTermGenerator { ... } -class BroadMatchGenerator implements SearchTermGenerator { ... } - -export const searchTermGenerators: Record<MatchType, SearchTermGenerator> = { - Exact: new ExactMatchGenerator(), - Phrase: new PhraseMatchGenerator(), - Broad: new BroadMatchGenerator(), -}; -``` - -### Generation Rules (Amazon-like) -| Match Type | Keyword | Generated Search Terms (examples) | -|------------|---------|-----------------------------------| -| **Exact** | "coffee filter" | "coffee filter", "coffee filters", "coffee filter" (close variants) | -| **Phrase** | "coffee filter" | "organic coffee filter", "best coffee filter", "coffee filter for chemex", "reusable coffee filter" | -| **Broad** | "coffee filter" | "cheap coffee filter", "coffee filter accessories", "coffee filter deals", "paper coffee filter", "reusable coffee filter", "metal coffee filter", "coffee filter holder" | - -### Negative Filtering -- Apply **during generation** (not after) — filtered terms never enter `campaign.searchTerms` -- Negative Exact: exact match -- Negative Phrase: substring match - ---- - -## TDD Compliance — Current State - -### ✅ Passing: 312 Tests (Engine Layer) -- All core engine tests pass -- Simulation tests pass (12 tests including match type generation, negative filtering) -- Feature slice tests pass - -### ❌ Missing: Component Tests (0 tests) -| Component | Required | -|-----------|----------| -| CreateCampaignWizard | 90% | -| CampaignManager | 85% | -| CampaignDetail | 85% | -| SearchTermsTab | 90% | -| NegativesTab | 90% | -| Wizard Steps | 90% each | - -### TDD Violations -1. No tests written FIRST for new features -2. React components have zero test coverage -3. Integration/E2E tests missing for critical flows - ---- - -## SOLID Compliance — Gap Analysis - -| Principle | Status | Action Required | -|-----------|--------|-----------------| -| **S** Single Responsibility | ❌ | Split wizard (6 components), CampaignDetail (8 tab components), CampaignManager (5 tab components) | -| **O** Open/Closed | ❌ | Search term generators hardcoded — extract to strategy pattern | -| **L** Liskov Substitution | ✅ | No violations | -| **I** Interface Segregation | ❌ | `AppStore` is God object — split into domain hooks/stores | -| **D** Dependency Inversion | ❌ | Components use `useAdConsoleStore` directly — use custom hooks | - ---- - -## Implementation Plan — 100% TDD + SOLID - -### Phase 1: Core Engine — Search Term Generators (TDD First) -**Files**: `search-term-generator.ts`, `search-term-generator.test.ts`, modify `simulation.ts` - -- [ ] Write tests for Exact/Phrase/Broad generators (distinct outputs, no duplicates) -- [ ] Write test: negative filtering applied DURING generation -- [ ] Implement generator classes with strategy pattern -- [ ] Integrate into `simulation.ts` (replace hardcoded `matchGens`) -- [ ] Run simulation tests — all pass - -### Phase 2: Campaign Wizard Refactor (SOLID - SRP) -**Files**: Extract 6 step components + campaign-type variants - -- [ ] Create `WizardStep` interface -- [ ] Extract `Step1AdType`, `Step2Basics`, `Step3ProductsCreative`, `Step4Targeting`, `Step5Bidding`, `Step6ReviewLaunch` -- [ ] Create campaign-type-specific variants in `wizard/steps/sp|sb|sd/` -- [ ] Refactor `CreateCampaignWizard` to orchestrator (<100 lines) -- [ ] Write component tests for each step (React Testing Library) - -### Phase 3: Campaign Views — Add Missing Metrics (TDD) -**Files**: `CampaignManager.tsx`, `CampaignDetail.tsx`, `SearchTermsTab.tsx` - -- [ ] Add Orders, Sales columns to Campaigns table + tests -- [ ] Add Impressions to SearchTermsTab + tests -- [ ] Add all 8 metrics to CampaignDetail Overview + tests -- [ ] Fix CampaignManager Search Terms tab negative filtering + tests - -### Phase 4: SB/SD Missing Flows (TDD) -**Files**: Wizard steps, simulation.ts - -- [ ] SB: Add search term generation in simulation + tests -- [ ] SB: Store Spotlight URL validation + test -- [ ] SB: Audience lookback dropdown in wizard + test -- [ ] SD: Audience lookback dropdown in wizard + test -- [ ] SD: Contextual category picker + test - -### Phase 5: Component Test Infrastructure -- [ ] Add `@testing-library/react`, `@testing-library/user-event`, `jsdom` -- [ ] Create test utilities: `renderWithStore()`, mock store helpers -- [ ] Write first component test (e.g., `CreateCampaignWizard.test.tsx`) - -### Phase 6: SOLID Refactoring -- [ ] Extract custom hooks: `useCampaignWizard`, `useCampaignManager`, `useCampaignDetail`, `useSearchTerms`, `useNegatives`, `useSimulation` -- [ ] Split `AppStore` into domain stores/hooks -- [ ] Each CampaignDetail tab → own component + hook - -### Phase 6: Error/Null States -- [ ] Empty states for all tabs (see table below) -- [ ] Inline validation errors (bid < 0.02, budget < 1, name required, etc.) - -### Phase 7: Campaign Click → Ad Groups Tab -- [ ] Modify `CampaignManager.selectCampaign()` to set tab to 'adgroups' - ---- - -## Empty/Error States Required - -| View | Empty Message | Action | -|------|---------------|--------| -| Campaigns | "No campaigns yet. Create your first campaign." | → Create campaign | -| Ad Groups | "No ad groups. Created automatically when campaign launches." | → Create campaign | -| Targets | "No targets. Add keywords, products, or audiences." | → Campaign → Targeting | -| Search Terms | "No search terms. Run simulation to generate from keywords." | → Run simulation | -| Search Terms (w/ negatives) | "All terms filtered by negatives. Check Negatives tab." | → Negatives tab | -| Negatives | "No negatives. Add to prevent wasted spend." | → Add negative form | - -| Error | Trigger | Display | -|-------|---------|---------| -| Name required | Launch w/o name | Inline: "Campaign name required" | -| Bid too low | Bid < 0.02 | Inline: "Minimum bid $0.02" | -| Budget too low | Budget < 1 | Inline: "Minimum budget $1" | -| No products | Launch w/o products | Inline: "Select at least one product" | -| Duplicate negative | Add existing | Toast: "Already exists" | - ---- - -## File Structure for New Code - -``` -src/ -├── engine/ad-console/core/ -│ ├── engine/ -│ │ ├── search-term-generator.ts # NEW - Strategy pattern -│ │ └── index.ts # Export -│ ├── simulation.ts # MODIFY - Use generators -│ └── __tests__/ -│ ├── search-term-generator.test.ts # NEW - TDD -│ └── simulation.test.ts # EXTEND -├── components/AdConsole/ -│ ├── wizard/ -│ │ ├── CreateCampaignWizard.tsx # REFACTOR <100 lines -│ │ ├── WizardStep.tsx # NEW - Interface -│ │ ├── Step1AdType.tsx # NEW -│ │ ├── Step2Basics.tsx # NEW -│ │ ├── Step3ProductsCreative.tsx # NEW -│ │ ├── Step4Targeting.tsx # NEW -│ │ ├── Step5Bidding.tsx # NEW -│ │ ├── Step6ReviewLaunch.tsx # NEW -│ │ └── steps/ -│ │ ├── sp/Step3.tsx, Step4.tsx, Step5.tsx -│ │ ├── sb/Step3.tsx, Step4.tsx, Step5.tsx -│ │ └── sd/Step3.tsx, Step4.tsx, Step5.tsx -│ ├── CampaignManager.tsx # REFACTOR - Extract tabs -│ ├── CampaignDetail.tsx # REFACTOR - Extract tabs -│ ├── details/ -│ │ ├── AdGroupsTab.tsx # NEW -│ │ ├── TargetsTab.tsx # NEW -│ │ ├── SearchTermsTab.tsx # MODIFY + Impressions -│ │ ├── NegativesTab.tsx # MODIFY -│ │ └── ... -│ └── hooks/ # NEW - Custom hooks -│ ├── useCampaignWizard.ts -│ ├── useCampaignManager.ts -│ ├── useCampaignDetail.ts -│ ├── useSearchTerms.ts -│ └── useNegatives.ts -└── __tests__/components/ # NEW - Component tests - ├── CreateCampaignWizard.test.tsx - ├── CampaignManager.test.tsx - ├── CampaignDetail.test.tsx - ├── SearchTermsTab.test.tsx - └── NegativesTab.test.tsx -``` - ---- - -## Quick Start for AI Coder - -```bash -cd /root/Documents/Codex/2026-07-16/install-github/Amazon-ad-console - -# 1. Verify baseline -npm test - -# 2. Phase 1: Search Term Generators (TDD) -# - Create search-term-generator.ts + test FIRST -# - Run test, implement, run test -# - Integrate into simulation.ts -# - Run all tests - -# 3. Add component test deps -npm install -D @testing-library/react @testing-library/user-event jsdom - -# 4. Phase 2-7: Follow plan above -# - Write test FIRST -# - Implement -# - Run tests frequently -``` - ---- - -## Key Types Reference (`types.ts`) - -```typescript -type CampaignType = 'SP' | 'SB' | 'SD'; -type MatchType = 'Exact' | 'Phrase' | 'Broad'; -type TargetingMode = 'Automatic' | 'Manual keyword' | 'Manual product' | 'Keyword' | 'Product' | 'Category' | 'Contextual' | 'Audiences - views remarketing' | 'Audiences - purchases remarketing' | 'Categories'; -type CampaignStatus = 'Enabled' | 'Paused' | 'Archived' | 'Draft'; -type BidStrategy = 'Dynamic bids - down only' | 'Dynamic bids - up and down' | 'Fixed bids' | 'Cost per click' | 'Cost per thousand impressions'; -``` - ---- - -## Engine Utilities (Use These) -- `calc(metrics)` → {ctr, cpc, acos, roas, cvr} -- `formatMoney(n)` → "$1,234.56" -- `formatWhole(n)` → "1,234" -- `formatPercent(n)` → "25.00%" -- `formatBid(n)` → "$0.75" -- `formatRoas(n)` → "4.00" -- `acosClass(acos)` → 'good'|'warn'|'bad' -- `generateId(prefix)` → unique ID -- `isFilteredByNegative(term, negatives)` → boolean - ---- - -## Definition of Done (100% Compliance) - -### TDD -- [ ] Every new function/module has tests written FIRST -- [ ] All 312+ existing tests pass -- [ ] Component tests >85% coverage for all React components -- [ ] Integration tests for wizard flow -- [ ] E2E tests for: create campaign → simulate → search terms → add negative - -### SOLID -- [ ] **S**: No component >200 lines, single responsibility -- [ ] **O**: Search generators extensible without modifying simulation.ts -- [ ] **L**: No LSP violations -- [ ] **I**: No God store; domain-specific hooks -- [ ] **D**: Components depend on hooks/interfaces, not concrete store - -### Features -- [ ] SP/SB/SD wizards complete with all fields -- [ ] Match types generate distinct search terms in simulation -- [ ] Negative phrase filters "word or anything containing that word" -- [ ] All views show: Impressions, Clicks, CPC, Spend, Sales, Orders, ACOS, ROAS -- [ ] Campaign click → Ad Groups tab (not Overview) -- [ ] Error/null states for all empty views -- [ ] SB search terms simulation works -- [ ] SD audience lookback in wizard -- [ ] SD contextual category picker in wizard diff --git a/legacy/amazon_ppc_simulator.html b/legacy/amazon_ppc_simulator.html deleted file mode 100644 index 9aa5746..0000000 --- a/legacy/amazon_ppc_simulator.html +++ /dev/null @@ -1,4592 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Amazon PPC Training Simulator V3.3 - - - -
- - - diff --git a/legacy/amazon_ppc_simulator_check.js b/legacy/amazon_ppc_simulator_check.js deleted file mode 100644 index 0899635..0000000 --- a/legacy/amazon_ppc_simulator_check.js +++ /dev/null @@ -1,3582 +0,0 @@ - - - -const $ = (sel, root = document) => root.querySelector(sel); - const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); - - const fmt = { - money: n => '$' + Number(n || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }), - whole: n => Number(n || 0).toLocaleString(), - pct: n => Number(n || 0).toFixed(2) + '%', - roas: n => Number(n || 0).toFixed(2), - bid: n => '$' + Number(n || 0).toFixed(2), - date: d => new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) - }; - - const copy = obj => JSON.parse(JSON.stringify(obj)); - const uid = prefix => prefix + '-' + Math.random().toString(36).slice(2, 8).toUpperCase(); - const APP_VERSION = '3.3'; - const LEGACY_STORAGE_KEYS = ['amazonPpcSimulator.v2']; - - - const products = [ - { asin: 'B0TRAIN001', sku: 'INS-100', title: 'AeroPress Style Stainless Coffee Filter', brand: 'Training Labs', price: 18.99, margin: 0.32, status: 'Retail Ready', rating: 4.6, reviews: 284, image: '☕' }, - { asin: 'B0TRAIN002', sku: 'INS-200', title: 'Milk Frother Pro USB Rechargeable', brand: 'Training Labs', price: 24.99, margin: 0.28, status: 'Retail Ready', rating: 4.4, reviews: 518, image: '🥛' }, - { asin: 'B0TRAIN003', sku: 'INS-300', title: 'Bamboo Coffee Pod Organizer Drawer', brand: 'Training Labs', price: 31.99, margin: 0.35, status: 'Low Inventory', rating: 4.1, reviews: 96, image: '🗄️' }, - { asin: 'B0TRAIN004', sku: 'INS-400', title: 'Premium Espresso Tamper 53mm', brand: 'Training Labs', price: 22.49, margin: 0.39, status: 'Retail Ready', rating: 4.7, reviews: 711, image: '🧰' }, - { asin: 'B0TRAIN005', sku: 'INS-500', title: 'Reusable Cold Brew Filter Bag 2 Pack', brand: 'Training Labs', price: 15.99, margin: 0.42, status: 'Retail Ready', rating: 4.5, reviews: 340, image: '🧊' } - ]; - - const keywordBank = [ - 'coffee filter reusable', 'stainless coffee filter', 'aeropress filter', 'espresso tamper', 'coffee accessories', 'milk frother rechargeable', - 'bamboo coffee organizer', 'coffee pod drawer', 'cold brew filter bag', 'reusable cold brew filter', 'barista tools', 'coffee gift set' - ]; - - const mockStorePages = [ - { id:'STORE-HOME', name:'Home page', products:['B0TRAIN001','B0TRAIN002','B0TRAIN003','B0TRAIN004','B0TRAIN005'], ready:true }, - { id:'STORE-COFFEE', name:'Coffee Accessories', products:['B0TRAIN001','B0TRAIN004','B0TRAIN005'], ready:true }, - { id:'STORE-BUNDLES', name:'Bundles and Gifts', products:['B0TRAIN001','B0TRAIN002','B0TRAIN005'], ready:true }, - { id:'STORE-ESPRESSO', name:'Espresso Tools', products:['B0TRAIN002','B0TRAIN004'], ready:true } - ]; - - const SCENARIO_KINDS = ['sp-waste','sb-creative','sd-scale']; - - const initialCampaigns = [ - { - id: 'C-SP-AUTO-001', type: 'SP', name: 'SP | Auto | Coffee Filter | Discovery', portfolio: 'Coffee Accessories', status: 'Enabled', dailyBudget: 35, startDate: '2026-06-01', endDate: '', targetingMode: 'Automatic', adFormat: 'Standard', bidStrategy: 'Dynamic bids - down only', defaultBid: 0.72, products: ['B0TRAIN001'], - placements: { top: 15, product: 0, rest: 0 }, budgetRules: [], negatives: [{ type: 'Negative exact', value: 'paper coffee filters' }], - metrics: { impressions: 88420, clicks: 1036, spend: 752.44, sales: 2160.55, orders: 124 }, - adGroups: [{ id: 'AG-001', name: 'Auto catch-all', status: 'Enabled', defaultBid: 0.72 }], - targets: [ - { id:'T-001', type:'Auto', value:'Close match', match:'Auto', bid:0.72, status:'Enabled', impressions:23000, clicks:330, spend:220.8, sales:940.2, orders:46 }, - { id:'T-002', type:'Auto', value:'Loose match', match:'Auto', bid:0.58, status:'Enabled', impressions:34000, clicks:312, spend:224.6, sales:410.4, orders:20 }, - { id:'T-003', type:'Auto', value:'Substitutes', match:'Auto', bid:0.64, status:'Enabled', impressions:21420, clicks:244, spend:178.3, sales:595.0, orders:32 }, - { id:'T-004', type:'Auto', value:'Complements', match:'Auto', bid:0.48, status:'Enabled', impressions:10000, clicks:150, spend:128.74, sales:214.95, orders:26 } - ], - searchTerms: [ - { id:'ST-001', term:'stainless coffee filter', target:'Close match', clicks:72, spend:44.22, sales:198.90, orders:11, recommendation:'Harvest as exact keyword' }, - { id:'ST-002', term:'paper coffee filters bulk', target:'Loose match', clicks:49, spend:39.80, sales:0, orders:0, recommendation:'Add negative exact or phrase' }, - { id:'ST-003', term:'aeropress filter', target:'Substitutes', clicks:65, spend:52.10, sales:139.95, orders:7, recommendation:'Harvest as exact keyword' }, - { id:'ST-004', term:'coffee grinder replacement part', target:'Complements', clicks:27, spend:22.44, sales:0, orders:0, recommendation:'Add negative phrase' } - ], - history: ['Campaign launched from template on Jun 1', 'Added negative exact: paper coffee filters'] - }, - { - id: 'C-SP-MAN-002', type: 'SP', name: 'SP | Manual | Coffee Filter | Exact Winners', portfolio: 'Coffee Accessories', status: 'Enabled', dailyBudget: 55, startDate: '2026-06-03', endDate: '', targetingMode: 'Manual keyword', adFormat: 'Standard', bidStrategy: 'Dynamic bids - up and down', defaultBid: 0.96, products: ['B0TRAIN001'], - placements: { top: 35, product: 10, rest: 0 }, budgetRules: [{ name:'Prime Day pulse', type:'Schedule', increase:50, condition:'Jul 7 to Jul 12' }], negatives: [], - metrics: { impressions: 61250, clicks: 980, spend: 930.44, sales: 3888.15, orders: 185 }, - adGroups: [{ id: 'AG-002', name: 'Exact and phrase', status: 'Enabled', defaultBid: 0.96 }], - targets: [ - { id:'T-005', type:'Keyword', value:'stainless coffee filter', match:'Exact', bid:1.18, status:'Enabled', impressions:21000, clicks:450, spend:531, sales:2250, orders:104 }, - { id:'T-006', type:'Keyword', value:'aeropress filter', match:'Exact', bid:1.05, status:'Enabled', impressions:16000, clicks:310, spend:325.5, sales:1230, orders:58 }, - { id:'T-007', type:'Keyword', value:'coffee filter reusable', match:'Phrase', bid:0.82, status:'Enabled', impressions:24250, clicks:220, spend:73.94, sales:408.15, orders:23 } - ], - searchTerms: [ - { id:'ST-005', term:'stainless coffee filter', target:'stainless coffee filter', clicks:310, spend:360.22, sales:1710.80, orders:78, recommendation:'Bid up carefully' }, - { id:'ST-006', term:'reusable metal coffee filter', target:'coffee filter reusable', clicks:88, spend:68.80, sales:219.90, orders:10, recommendation:'Harvest as exact keyword' } - ], - history: ['Launched manual exact campaign', 'Top of Search increased to 35%'] - }, - { - id: 'C-SP-PAT-003', type: 'SP', name: 'SP | Product Targeting | Competitor ASINs', portfolio: 'Coffee Accessories', status: 'Enabled', dailyBudget: 45, startDate: '2026-06-05', endDate: '', targetingMode: 'Manual product', adFormat: 'Standard', bidStrategy: 'Fixed bids', defaultBid: 0.66, products: ['B0TRAIN004'], - placements: { top: 0, product: 25, rest: 0 }, budgetRules: [], negatives: [], - metrics: { impressions: 42100, clicks: 474, spend: 341.18, sales: 877.11, orders: 39 }, - adGroups: [{ id: 'AG-003', name: 'Competitor detail pages', status: 'Enabled', defaultBid: 0.66 }], - targets: [ - { id:'T-008', type:'ASIN', value:'B00COMP991', match:'Product', bid:0.70, status:'Enabled', impressions:12000, clicks:130, spend:91, sales:202.41, orders:9 }, - { id:'T-009', type:'ASIN', value:'B00COMP992', match:'Product', bid:0.62, status:'Enabled', impressions:16800, clicks:210, spend:136.5, sales:497.80, orders:22 }, - { id:'T-010', type:'Category', value:'Coffee Tampers', match:'Category', bid:0.55, status:'Enabled', impressions:13300, clicks:134, spend:113.68, sales:176.90, orders:8 } - ], - searchTerms: [], - history: ['Product targeting campaign created'] - }, - { - id: 'C-SB-PC-004', type: 'SB', name: 'SB | Product Collection | Coffee Accessories', portfolio: 'Brand Growth', status: 'Enabled', dailyBudget: 80, startDate: '2026-06-02', endDate: '', targetingMode: 'Keyword and product', adFormat: 'Product collection', bidStrategy: 'Cost per click', defaultBid: 1.20, products: ['B0TRAIN001','B0TRAIN002','B0TRAIN004'], - creative: { headline:'Upgrade your home coffee bar', brandName:'Training Labs', logo:'TL', destination:'Brand Store', video:'', image:'Lifestyle' }, - placements: { top: 20, product: 0, rest: 0 }, budgetRules: [], negatives: [], - metrics: { impressions: 128500, clicks: 1250, spend: 1512.50, sales: 4080.30, orders: 172 }, - adGroups: [{ id:'AG-004', name:'Brand collection keywords', status:'Enabled', defaultBid:1.20 }], - targets: [ - { id:'T-011', type:'Keyword', value:'coffee accessories', match:'Phrase', bid:1.10, status:'Enabled', impressions:46000, clicks:410, spend:451, sales:1380, orders:58 }, - { id:'T-012', type:'Keyword', value:'coffee gift set', match:'Broad', bid:1.00, status:'Enabled', impressions:52000, clicks:420, spend:458, sales:830.60, orders:35 }, - { id:'T-013', type:'Category', value:'Coffee, Tea & Espresso', match:'Category', bid:1.25, status:'Enabled', impressions:30500, clicks:420, spend:603.5, sales:1869.7, orders:79 } - ], - searchTerms: [ - { id:'ST-007', term:'home coffee accessories', target:'coffee accessories', clicks:102, spend:118.22, sales:402.70, orders:17, recommendation:'Split into exact' }, - { id:'ST-008', term:'coffee wall art', target:'coffee gift set', clicks:43, spend:51.90, sales:0, orders:0, recommendation:'Negative phrase' } - ], - history: ['Sponsored Brands product collection launched', 'Creative approved in simulator'] - }, - { - id: 'C-SB-VID-005', type: 'SB', name: 'SB | Video | Milk Frother Demo', portfolio: 'Brand Growth', status: 'Paused', dailyBudget: 65, startDate: '2026-06-08', endDate: '', targetingMode: 'Keyword', adFormat: 'Video', bidStrategy: 'Cost per click', defaultBid: 1.45, products: ['B0TRAIN002'], - creative: { headline:'Cafe foam at home', brandName:'Training Labs', logo:'TL', destination:'Product detail page', video:'Frother-demo-30s.mp4', image:'Video thumbnail' }, - placements: { top: 30, product: 0, rest: 0 }, budgetRules: [], negatives: [], - metrics: { impressions: 42100, clicks: 520, spend: 702.00, sales: 2010.45, orders: 83 }, - adGroups: [{ id:'AG-005', name:'Video exact', status:'Paused', defaultBid:1.45 }], - targets: [ - { id:'T-014', type:'Keyword', value:'milk frother rechargeable', match:'Exact', bid:1.55, status:'Paused', impressions:24000, clicks:330, spend:511.5, sales:1460.2, orders:60 }, - { id:'T-015', type:'Keyword', value:'electric milk frother', match:'Phrase', bid:1.20, status:'Paused', impressions:18100, clicks:190, spend:190.5, sales:550.25, orders:23 } - ], - searchTerms: [], - history: ['Paused for creative refresh'] - }, - { - id: 'C-SD-CTX-006', type: 'SD', name: 'SD | Contextual | Coffee Detail Pages', portfolio: 'Defensive and Remarketing', status: 'Enabled', dailyBudget: 60, startDate: '2026-06-04', endDate: '', targetingMode: 'Contextual', adFormat: 'Custom image', bidStrategy: 'Cost per click', defaultBid: 0.92, products: ['B0TRAIN004','B0TRAIN005'], - creative: { headline:'Complete your coffee setup', brandName:'Training Labs', logo:'TL', destination:'Product detail page', video:'', image:'Custom image' }, - placements: { top: 0, product: 40, rest: 0 }, budgetRules: [], negatives: [], - metrics: { impressions: 94500, clicks: 760, spend: 714.40, sales: 2044.80, orders: 88 }, - adGroups: [{ id:'AG-006', name:'Contextual categories', status:'Enabled', defaultBid:0.92 }], - targets: [ - { id:'T-016', type:'Category', value:'Coffee Organizers', match:'Contextual', bid:0.90, status:'Enabled', impressions:38000, clicks:310, spend:279, sales:928.5, orders:38 }, - { id:'T-017', type:'ASIN', value:'B00COMP993', match:'Contextual', bid:0.95, status:'Enabled', impressions:29000, clicks:230, spend:218.5, sales:548.1, orders:24 }, - { id:'T-018', type:'Category', value:'Espresso Accessories', match:'Contextual', bid:0.88, status:'Enabled', impressions:27500, clicks:220, spend:216.9, sales:568.2, orders:26 } - ], - searchTerms: [], - history: ['Sponsored Display contextual campaign launched'] - }, - { - id: 'C-SD-AUD-007', type: 'SD', name: 'SD | Views Remarketing | 30 Day', portfolio: 'Defensive and Remarketing', status: 'Enabled', dailyBudget: 70, startDate: '2026-06-06', endDate: '', targetingMode: 'Audiences - views remarketing', adFormat: 'Auto generated', bidStrategy: 'Cost per click', defaultBid: 0.78, products: ['B0TRAIN001','B0TRAIN002','B0TRAIN005'], - creative: { headline:'Still comparing coffee gear?', brandName:'Training Labs', logo:'TL', destination:'Product detail page', video:'', image:'Auto generated' }, - placements: { top: 0, product: 10, rest: 0 }, budgetRules: [], negatives: [], - metrics: { impressions: 153200, clicks: 920, spend: 690.00, sales: 3450.70, orders: 160 }, - adGroups: [{ id:'AG-007', name:'Viewed detail pages, no purchase', status:'Enabled', defaultBid:0.78 }], - targets: [ - { id:'T-019', type:'Audience', value:'Viewed advertised products, 30 days', match:'Remarketing', bid:0.82, status:'Enabled', impressions:98000, clicks:650, spend:533, sales:2860.4, orders:131 }, - { id:'T-020', type:'Audience', value:'Viewed similar products, 14 days', match:'Remarketing', bid:0.62, status:'Enabled', impressions:55200, clicks:270, spend:157, sales:590.3, orders:29 } - ], - searchTerms: [], - history: ['Views remarketing audience launched'] - } - ]; - - const scenarioTemplates = [ - { - id: 'mission-sp-harvest', type:'SP', title: 'SP Search Term Harvest and Negation', difficulty:'Core VA', minutes: 18, - summary: 'Find waste in an automatic Sponsored Products campaign, add a negative, then harvest a converting term into a manual exact campaign.', - startView: 'campaigns', - objectives: [ - { id:'open-auto', text:'Open SP auto discovery campaign', check: s => s.selectedCampaignId === 'C-SP-AUTO-001' }, - { id:'view-search-terms', text:'Navigate to Search terms tab', check: s => s.selectedTab === 'searchTerms' && s.selectedCampaignId === 'C-SP-AUTO-001' }, - { id:'negative-waste', text:'Add a negative for a high-spend, zero-sale search term', check: s => hasNegative(s, 'C-SP-AUTO-001', 'paper coffee filters bulk') || hasNegative(s, 'C-SP-AUTO-001', 'coffee grinder replacement part') }, - { id:'harvest-winner', text:'Harvest a converting search term as exact target in the manual campaign', check: s => campaignById(s, 'C-SP-MAN-002').targets.some(t => ['stainless coffee filter','aeropress filter','reusable metal coffee filter'].includes(t.value) && t.match === 'Exact') } - ], - coach: 'Think like a PPC operator: search terms with spend and no orders get blocked. Search terms with orders move into exact control campaigns.' - }, - { - id: 'mission-sp-build', type:'SP', title: 'Build Sponsored Products Campaign', difficulty:'Setup', minutes: 22, - summary: 'Create a clean SP manual campaign with products, keywords, default bid, placement adjustment, and daily budget.', - startView: 'create', - objectives: [ - { id:'created-sp', text:'Launch a Sponsored Products campaign', check: s => s.campaigns.some(c => c.createdBySimulator && c.type === 'SP') }, - { id:'manual-keyword', text:'Use manual keyword targeting', check: s => s.campaigns.some(c => c.createdBySimulator && c.type === 'SP' && c.targetingMode.includes('Manual keyword')) }, - { id:'budget-ok', text:'Set daily budget at $25 or higher', check: s => s.campaigns.some(c => c.createdBySimulator && c.type === 'SP' && Number(c.dailyBudget) >= 25) }, - { id:'keywords-ok', text:'Add at least three keyword targets', check: s => s.campaigns.some(c => c.createdBySimulator && c.type === 'SP' && c.targets.length >= 3) } - ], - coach: 'For training, a good SP build forces the VA to understand campaign level, ad group level, targets, bids, and budget.' - }, - { - id: 'mission-sb-build', type:'SB', title: 'Build Sponsored Brands Product Collection', difficulty:'Brand Ads', minutes: 25, - summary: 'Create an SB campaign with headline, brand name, at least three products, keywords, and a destination.', - startView: 'create', - objectives: [ - { id:'created-sb', text:'Launch a Sponsored Brands campaign', check: s => s.campaigns.some(c => c.createdBySimulator && c.type === 'SB') }, - { id:'sb-format', text:'Choose Product collection, Store spotlight, or Video format', check: s => s.campaigns.some(c => c.createdBySimulator && c.type === 'SB' && ['Product collection','Store spotlight','Video'].includes(c.adFormat)) }, - { id:'sb-creative', text:'Add brand name and headline', check: s => s.campaigns.some(c => c.createdBySimulator && c.type === 'SB' && c.creative && c.creative.brandName && c.creative.headline) }, - { id:'sb-products', text:'Select three products for Product collection or one product for Video', check: s => s.campaigns.some(c => c.createdBySimulator && c.type === 'SB' && ((c.adFormat === 'Video' && c.products.length >= 1) || (c.adFormat !== 'Video' && c.products.length >= 3))) } - ], - coach: 'Sponsored Brands trains creative checks: headline, logo, destination, format, and product selection. Miss one and the launch should fail.' - }, - { - id: 'mission-sd-build', type:'SD', title: 'Build Sponsored Display Remarketing', difficulty:'Display Ads', minutes: 20, - summary: 'Create an SD campaign using audiences or contextual targeting with product selection, creative type, bid, and budget.', - startView: 'create', - objectives: [ - { id:'created-sd', text:'Launch a Sponsored Display campaign', check: s => s.campaigns.some(c => c.createdBySimulator && c.type === 'SD') }, - { id:'sd-tactic', text:'Use contextual or audience targeting', check: s => s.campaigns.some(c => c.createdBySimulator && c.type === 'SD' && (c.targetingMode.includes('Contextual') || c.targetingMode.includes('Audiences'))) }, - { id:'sd-creative', text:'Choose auto generated or custom image creative', check: s => s.campaigns.some(c => c.createdBySimulator && c.type === 'SD' && c.adFormat) }, - { id:'sd-budget', text:'Set daily budget at $20 or higher', check: s => s.campaigns.some(c => c.createdBySimulator && c.type === 'SD' && Number(c.dailyBudget) >= 20) } - ], - coach: 'Sponsored Display is not search-term work. Train the VA to think in audiences, contextual targets, products, and creative.' - }, - { - id: 'mission-budget-placement', type:'Ops', title: 'Budget and Placement Controls', difficulty:'Operator', minutes: 16, - summary: 'Diagnose a campaign, adjust budget, update Top of Search or product pages placement, and create a simple budget rule.', - startView: 'campaigns', - objectives: [ - { id:'budget-changed', text:'Change a campaign daily budget', check: s => s.actionLog.some(a => a.type === 'budget_change') }, - { id:'placement-changed', text:'Change placement adjustment', check: s => s.actionLog.some(a => a.type === 'placement_change') }, - { id:'rule-created', text:'Create a budget rule', check: s => s.actionLog.some(a => a.type === 'budget_rule_created') }, - { id:'history-read', text:'Open Change history tab', check: s => s.selectedTab === 'history' } - ], - coach: 'Budget rules and placement multipliers are where beginners quietly break accounts. Great training minefield.' - } - ]; - - const state = { - view: 'dashboard', - campaigns: copy(initialCampaigns), - selectedCampaignId: '', - selectedTab: 'campaigns', - filterType: 'All', - filterStatus: 'All', - search: '', - dateRange: 'Last 30 days', - draft: makeDraft(), - wizardStep: 1, - toasts: [], - activeScenarioId: '', - actionLog: [], - feedbackLog: [], - traineeName: 'Trainee 1', - trainerNotes: '', - bulkInput: 'entity,operation,campaignId,adGroupId,targetId,target,value,bid,type,status,placement,percentage,condition\nCampaign,Update,C-SP-AUTO-001,,,,42,,,,,,\nTarget,Update,C-SP-MAN-002,,T-005,stainless coffee filter,,1.32,,,,,\nTarget,Create,C-SP-MAN-002,AG-002,,,low intent coffee mug,0.31,Keyword,Enabled,,,\nNegative,Create,C-SP-AUTO-001,,,,paper coffee filters bulk,,Negative exact,,,,\nPlacement,Update,C-SP-MAN-002,,,,,,,,top,35,\nBudgetRule,Create,C-SP-MAN-002,,,,Prime Day pulse,,Schedule,,,25,Prime Day event week', - bulkPreview: [], - showHints: true, - simulationDays: 0, - reportType: 'Search term report', - reportQueue: [], - selectedReportId: '', - generatedScenarios: [], - scenarioDifficulty: 'Intermediate', - integrityLastRun: '', - lastIntegrityScore: 0 - }; - - const STORAGE_KEY = 'amazonPpcSimulator.v3'; - function serializableState() { - const snap = copy(state); - snap.toasts = []; - return snap; - } - function persistState() { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(serializableState())); } catch (e) {} - } - function hydrateState() { - try { - let raw = localStorage.getItem(STORAGE_KEY); - if (!raw) { - for (const key of LEGACY_STORAGE_KEYS) { - raw = localStorage.getItem(key); - if (raw) break; - } - } - if (!raw) return; - const saved = JSON.parse(raw); - Object.keys(saved).forEach(k => { if (k in state && k !== 'toasts') state[k] = saved[k]; }); - state.toasts = []; - } catch (e) {} - } - hydrateState(); - - function makeDraft() { - return { - type: 'SP', - name: '', portfolio: 'Training Portfolio', status: 'Enabled', dailyBudget: 25, startDate: '2026-06-25', endDate: '', - targetingMode: 'Automatic', adFormat: 'Standard', bidStrategy: 'Dynamic bids - down only', defaultBid: 0.75, - products: ['B0TRAIN001'], placements: { top: 0, product: 0, rest: 0 }, budgetRules: [], negatives: [], - keywords: 'coffee filter reusable\nstainless coffee filter\naeropress filter', matchType: 'Exact', asinTargets: 'B00COMP001\nB00COMP002', categoryTargets: 'Coffee, Tea & Espresso', audienceTargets: 'Viewed advertised products, 30 days', - creative: { headline:'Upgrade your home coffee bar', brandName:'Training Labs', logo:'TL', destination:'Brand Store', video:'', image:'Auto generated' }, - optimization: 'Conversions' - }; - } - - - function cleanIdPart(value) { - return String(value || '').toUpperCase().replace(/[^A-Z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'ITEM'; - } - - function uniqueList(list) { - return Array.from(new Set((Array.isArray(list) ? list : []).filter(Boolean))); - } - - function metricDefaults(row) { - row.impressions = Number(row.impressions || 0); - row.clicks = Number(row.clicks || 0); - row.spend = Number(row.spend || 0); - row.sales = Number(row.sales || 0); - row.orders = Number(row.orders || 0); - return row; - } - - function getPrimaryAdGroup(c) { - if (!Array.isArray(c.adGroups)) c.adGroups = []; - if (!c.adGroups.length) c.adGroups.push({ id: uid('AG'), name: `${c.type || 'SP'} default ad group`, status: c.status || 'Paused', defaultBid: Number(c.defaultBid || 0.75), campaignId: c.id }); - return c.adGroups[0]; - } - - function normalizeState() { - if (!Array.isArray(state.campaigns)) state.campaigns = copy(initialCampaigns); - state.campaigns.forEach(normalizeCampaign); - state.generatedScenarios = Array.isArray(state.generatedScenarios) ? state.generatedScenarios.slice(0, 8) : []; - state.reportQueue = Array.isArray(state.reportQueue) ? state.reportQueue : []; - state.bulkPreview = Array.isArray(state.bulkPreview) ? state.bulkPreview : []; - state.lastIntegrityScore = integrityScore().score; - } - - function normalizeCampaign(c) { - c.type = ['SP','SB','SD'].includes(c.type) ? c.type : 'SP'; - c.id = c.id || uid('C-' + c.type); - c.name = c.name || `${c.type} | Training campaign`; - c.portfolio = c.portfolio || 'Training Portfolio'; - c.status = ['Enabled','Paused','Archived','Draft'].includes(c.status) ? c.status : 'Paused'; - c.dailyBudget = Math.max(1, Number(c.dailyBudget || 1)); - c.defaultBid = Math.max(0.02, Number(c.defaultBid || 0.75)); - c.startDate = c.startDate || '2026-06-25'; - c.endDate = c.endDate || ''; - c.targetingMode = c.targetingMode || (c.type === 'SP' ? 'Automatic' : c.type === 'SB' ? 'Keyword' : 'Contextual'); - c.adFormat = c.adFormat || (c.type === 'SB' ? 'Product collection' : c.type === 'SD' ? 'Auto generated' : 'Standard'); - c.bidStrategy = c.bidStrategy || (c.type === 'SP' ? 'Dynamic bids - down only' : 'Cost per click'); - c.products = uniqueList(c.products && c.products.length ? c.products : ['B0TRAIN001']); - c.placements = Object.assign({ top:0, product:0, rest:0 }, c.placements || {}); - ['top','product','rest'].forEach(k => c.placements[k] = Math.max(0, Math.min(900, Number(c.placements[k] || 0)))); - c.metrics = metricDefaults(Object.assign({ impressions:0, clicks:0, spend:0, sales:0, orders:0 }, c.metrics || {})); - c.history = Array.isArray(c.history) ? c.history : []; - c.adGroups = (Array.isArray(c.adGroups) && c.adGroups.length ? c.adGroups : [{ id: uid('AG'), name: `${c.type} default ad group`, status: c.status, defaultBid: c.defaultBid }]).map((ag, i) => ({ - id: ag.id || uid('AG'), - campaignId: c.id, - name: ag.name || `${c.type} ad group ${i + 1}`, - status: ['Enabled','Paused','Archived','Draft'].includes(ag.status) ? ag.status : c.status, - defaultBid: Math.max(0.02, Number(ag.defaultBid || c.defaultBid)) - })); - const primary = getPrimaryAdGroup(c); - c.ads = Array.isArray(c.ads) ? c.ads : []; - const existingAds = new Map(c.ads.map(ad => [ad.asin, ad])); - c.products.forEach((asin, i) => { - if (!existingAds.has(asin)) c.ads.push({ id: uid('AD'), campaignId: c.id, adGroupId: primary.id, asin, status: c.status === 'Archived' ? 'Archived' : 'Enabled', name: `Product ad ${i + 1}` }); - }); - c.ads = c.ads.filter(ad => c.products.includes(ad.asin)).map((ad, i) => ({ - id: ad.id || uid('AD'), campaignId: c.id, adGroupId: c.adGroups.some(ag => ag.id === ad.adGroupId) ? ad.adGroupId : primary.id, asin: ad.asin, status: ['Enabled','Paused','Archived'].includes(ad.status) ? ad.status : c.status, name: ad.name || `Product ad ${i + 1}` - })); - c.targets = (Array.isArray(c.targets) ? c.targets : []).map(t => metricDefaults(Object.assign({ - id: t.id || uid('T'), campaignId: c.id, adGroupId: c.adGroups.some(ag => ag.id === t.adGroupId) ? t.adGroupId : primary.id, type: t.type || 'Keyword', value: t.value || 'training target', match: t.match || 'Exact', bid: Math.max(0.02, Number(t.bid || c.defaultBid)), status: ['Enabled','Paused','Archived'].includes(t.status) ? t.status : c.status - }, t, { campaignId: c.id, adGroupId: c.adGroups.some(ag => ag.id === t.adGroupId) ? t.adGroupId : primary.id, bid: Math.max(0.02, Number(t.bid || c.defaultBid)) }))); - c.searchTerms = (Array.isArray(c.searchTerms) ? c.searchTerms : []).map(st => metricDefaults(Object.assign({ - id: st.id || uid('ST'), campaignId: c.id, adGroupId: c.adGroups.some(ag => ag.id === st.adGroupId) ? st.adGroupId : primary.id, term: st.term || '', target: st.target || '', recommendation: st.recommendation || 'Review' - }, st, { campaignId: c.id, adGroupId: c.adGroups.some(ag => ag.id === st.adGroupId) ? st.adGroupId : primary.id }))); - c.searchTerms.forEach(st => { st.targetId = linkSearchTermTarget(c, st); if (!st.target && st.targetId) st.target = (c.targets.find(t => t.id === st.targetId) || {}).value || ''; }); - c.negatives = (Array.isArray(c.negatives) ? c.negatives : []).map(n => ({ - id: n.id || uid('NEG'), campaignId: c.id, adGroupId: c.adGroups.some(ag => ag.id === n.adGroupId) ? n.adGroupId : primary.id, type: n.type || 'Negative exact', value: n.value || '', sourceSearchTermId: n.sourceSearchTermId || '' - })).filter(n => n.value); - c.budgetRules = (Array.isArray(c.budgetRules) ? c.budgetRules : []).map((r, i) => ({ - id: r.id || uid('BR'), campaignId: c.id, name: r.name || `Budget rule ${i + 1}`, type: r.type || 'Schedule', increase: Math.max(1, Number(r.increase || 1)), condition: r.condition || 'Training condition' - })); - if (c.type !== 'SP') { - c.creative = Object.assign({ headline:'', brandName:'', logo:'', destination:'Product detail page', video:'', image:'Auto generated' }, c.creative || {}); - c.creativeStatus = c.creativeStatus || 'Approved'; - c.creativeIssue = c.creativeIssue || ''; - } - if (c.status === 'Archived') { - c.adGroups.forEach(ag => ag.status = 'Archived'); - c.ads.forEach(ad => ad.status = 'Archived'); - c.targets.forEach(t => t.status = 'Archived'); - } - } - - function linkSearchTermTarget(c, st) { - if (!st) return ''; - if (st.targetId && c.targets.some(t => t.id === st.targetId)) return st.targetId; - const targetText = String(st.target || '').toLowerCase(); - const termText = String(st.term || '').toLowerCase(); - const exact = c.targets.find(t => String(t.value || '').toLowerCase() === targetText); - if (exact) return exact.id; - const auto = c.targets.find(t => String(t.value || '').toLowerCase() === targetText || String(t.match || '').toLowerCase() === targetText); - if (auto) return auto.id; - const fuzzy = c.targets.find(t => targetText && String(t.value || '').toLowerCase().includes(targetText)) || c.targets.find(t => termText && termText.includes(String(t.value || '').toLowerCase())); - return fuzzy ? fuzzy.id : ''; - } - - function allScenarios() { - return scenarioTemplates.concat((state.generatedScenarios || []).map(hydrateGeneratedScenario)); - } - - function hydrateGeneratedScenario(gs) { - return Object.assign({}, gs, { objectives: scenarioObjectivesFor(gs) }); - } - - function scenarioObjectivesFor(gs) { - if (gs.kind === 'sp-waste') return [ - { id:'open-generated-sp', text:'Open the generated SP discovery campaign', check: s => s.selectedCampaignId === gs.subjectId }, - { id:'view-generated-terms', text:'Open Search terms for the generated campaign', check: s => s.selectedCampaignId === gs.subjectId && s.selectedTab === 'searchTerms' }, - { id:'negate-generated-waste', text:'Add a negative for the zero-sale waste term', check: s => hasNegative(s, gs.subjectId, gs.wasteTerm) }, - { id:'harvest-generated-winner', text:'Harvest the converting term into an exact SP manual target', check: s => s.campaigns.some(c => c.type === 'SP' && c.targetingMode.includes('Manual') && c.targets.some(t => t.value.toLowerCase() === gs.winnerTerm.toLowerCase() && t.match === 'Exact')) } - ]; - if (gs.kind === 'sb-creative') return [ - { id:'open-generated-sb', text:'Open the rejected SB creative campaign', check: s => s.selectedCampaignId === gs.subjectId }, - { id:'review-generated-sb', text:'Review campaign overview and creative issue', check: s => s.selectedCampaignId === gs.subjectId && s.selectedTab === 'overview' }, - { id:'repair-generated-sb', text:'Fix the creative rejection in the simulator', check: s => { const c = campaignById(s, gs.subjectId); return c && c.creativeStatus === 'Approved'; } }, - { id:'enable-generated-sb', text:'Enable the campaign after approval', check: s => { const c = campaignById(s, gs.subjectId); return c && c.status === 'Enabled'; } } - ]; - if (gs.kind === 'sd-scale') return [ - { id:'open-generated-sd', text:'Open the SD remarketing campaign', check: s => s.selectedCampaignId === gs.subjectId }, - { id:'view-placement-sd', text:'Open placement controls', check: s => s.selectedCampaignId === gs.subjectId && s.selectedTab === 'placements' }, - { id:'budget-rule-sd', text:'Create a budget rule for the winning audience', check: s => { const c = campaignById(s, gs.subjectId); return c && c.budgetRules.length > 0; } }, - { id:'simulate-sd', text:'Run 7-day simulation after scaling decision', check: s => s.actionLog.some(a => a.type === 'simulation_run') } - ]; - return []; - } - - function integritySummary() { - const campaigns = state.campaigns.length; - const adGroups = state.campaigns.reduce((a,c)=>a+(c.adGroups||[]).length,0); - const ads = state.campaigns.reduce((a,c)=>a+(c.ads||[]).length,0); - const targets = state.campaigns.reduce((a,c)=>a+(c.targets||[]).length,0); - const searchTerms = state.campaigns.reduce((a,c)=>a+(c.searchTerms||[]).length,0); - const negatives = state.campaigns.reduce((a,c)=>a+(c.negatives||[]).length,0); - const rules = state.campaigns.reduce((a,c)=>a+(c.budgetRules||[]).length,0); - return { campaigns, adGroups, ads, targets, searchTerms, negatives, rules }; - } - - function integrityChecks() { - const checks = []; - const add = (severity, entity, message, fix='') => checks.push({ severity, entity, message, fix }); - const campaignIds = new Set(); - const productSet = new Set(products.map(p => p.asin)); - state.campaigns.forEach(c => { - if (campaignIds.has(c.id)) add('error', c.id, 'Duplicate campaign ID detected.', 'Duplicate or regenerate one campaign ID.'); - campaignIds.add(c.id); - if (!c.adGroups || !c.adGroups.length) add('error', c.id, 'Campaign has no ad group.', 'Run self-heal to create a default ad group.'); - if (c.dailyBudget <= 0) add('error', c.id, 'Daily budget is not valid.', 'Set a daily budget above zero.'); - if (c.defaultBid <= 0) add('error', c.id, 'Default bid is not valid.', 'Set a default bid above zero.'); - if (c.endDate && c.startDate && new Date(c.endDate) < new Date(c.startDate)) add('error', c.id, 'End date is before start date.', 'Move the end date after the start date.'); - (c.products || []).forEach(asin => { - if (!productSet.has(asin)) add('error', c.id, `Product ASIN ${asin} does not exist in product catalog.`, 'Use a valid catalog ASIN.'); - const p = products.find(x => x.asin === asin); - if (p && p.status !== 'Retail Ready' && c.status === 'Enabled') add('warn', c.id, `${asin} is ${p.status} while campaign is enabled.`, 'Pause scaling or swap to a retail-ready product.'); - }); - const adGroupIds = new Set((c.adGroups || []).map(ag => ag.id)); - (c.adGroups || []).forEach(ag => { - if (ag.campaignId !== c.id) add('error', ag.id, 'Ad group points to the wrong campaign.', 'Run self-heal to relink ad group.'); - if (c.status === 'Archived' && ag.status !== 'Archived') add('error', ag.id, 'Archived campaign has non-archived ad group.', 'Archive children with parent.'); - }); - (c.ads || []).forEach(ad => { - if (ad.campaignId !== c.id) add('error', ad.id, 'Product ad points to the wrong campaign.', 'Run self-heal to relink product ad.'); - if (!adGroupIds.has(ad.adGroupId)) add('error', ad.id, 'Product ad has missing ad group reference.', 'Attach to an existing ad group.'); - if (!productSet.has(ad.asin)) add('error', ad.id, `Product ad ASIN ${ad.asin} is not in catalog.`, 'Use a valid catalog ASIN.'); - }); - (c.targets || []).forEach(t => { - if (t.campaignId !== c.id) add('error', t.id, 'Target points to the wrong campaign.', 'Run self-heal to relink target.'); - if (!adGroupIds.has(t.adGroupId)) add('error', t.id, 'Target has missing ad group reference.', 'Attach to an existing ad group.'); - if (Number(t.bid) <= 0) add('error', t.id, 'Target bid is not valid.', 'Set a bid above zero.'); - if (c.status === 'Archived' && t.status !== 'Archived') add('error', t.id, 'Archived campaign has non-archived target.', 'Archive children with parent.'); - }); - (c.searchTerms || []).forEach(st => { - if (st.campaignId !== c.id) add('error', st.id, 'Search term points to the wrong campaign.', 'Run self-heal to relink search term.'); - if (c.type === 'SD') add('warn', st.id, 'Sponsored Display campaign has search term rows.', 'Use audience/contextual reports for SD instead.'); - if (c.type !== 'SD' && !st.targetId) add('warn', st.id, `Search term "${st.term}" has no target link.`, 'Attach it to the matched target.'); - }); - const negativeKeys = new Set(); - (c.negatives || []).forEach(n => { - const key = `${n.type}|${String(n.value).toLowerCase()}`; - if (negativeKeys.has(key)) add('warn', n.id, 'Duplicate negative targeting row.', 'Remove duplicate negative.'); - negativeKeys.add(key); - if (n.campaignId !== c.id) add('error', n.id, 'Negative points to the wrong campaign.', 'Run self-heal to relink negative.'); - }); - (c.budgetRules || []).forEach(r => { - if (r.campaignId !== c.id) add('error', r.id, 'Budget rule points to the wrong campaign.', 'Run self-heal to relink budget rule.'); - if (Number(r.increase) <= 0 || Number(r.increase) > 200) add('warn', r.id, 'Budget rule increase is outside the training safe range.', 'Use 1% to 200%.'); - }); - if (c.type === 'SB') { - if (c.adFormat === 'Store spotlight' && c.creative.destination !== 'Brand Store') add('error', c.id, 'Store Spotlight should use a Brand Store destination.', 'Switch destination to Brand Store.'); - if (c.adFormat === 'Store spotlight' && mockStorePages.filter(p => p.ready && p.products.length).length < 4) add('error', c.id, 'Store Spotlight needs at least four ready Store pages in this simulator.', 'Prepare more Store pages.'); - if (c.adFormat === 'Product collection' && c.products.length < 3) add('error', c.id, 'SB Product Collection needs at least three products.', 'Select three retail-ready products.'); - if (c.adFormat === 'Video' && !c.creative.video) add('error', c.id, 'SB Video has no video asset placeholder.', 'Add a video asset placeholder.'); - if (c.creativeStatus === 'Rejected') add('warn', c.id, `Creative rejected: ${c.creativeIssue || 'review needed'}`, 'Use Fix creative approval.'); - } - if (c.type === 'SD' && !(c.targetingMode.includes('Contextual') || c.targetingMode.includes('Audiences'))) add('error', c.id, 'SD targeting mode is not contextual or audience-based.', 'Choose contextual or audience targeting.'); - }); - return checks; - } - - function integrityScore() { - const checks = integrityChecks(); - const errors = checks.filter(x => x.severity === 'error').length; - const warnings = checks.filter(x => x.severity === 'warn').length; - return { checks, errors, warnings, score: Math.max(0, Math.min(100, 100 - errors * 15 - warnings * 5)) }; - } - - function autoRepairState() { - normalizeState(); - state.campaigns.forEach(c => { - if (c.status === 'Archived') { c.adGroups.forEach(ag => ag.status = 'Archived'); c.ads.forEach(ad => ad.status = 'Archived'); c.targets.forEach(t => t.status = 'Archived'); } - const seen = new Set(); - c.negatives = c.negatives.filter(n => { const key = `${n.type}|${String(n.value).toLowerCase()}`; if (seen.has(key)) return false; seen.add(key); return true; }); - }); - state.integrityLastRun = new Date().toISOString(); - logAction('integrity_repair', 'Relationship self-heal completed', 'good'); - toast('Integrity self-heal completed.', 'good'); - render(); - } - - function relationshipRows() { - return state.campaigns.map(c => ({ - campaign: c, - adGroups: c.adGroups || [], - ads: c.ads || [], - targets: c.targets || [], - searchTerms: c.searchTerms || [], - negatives: c.negatives || [], - rules: c.budgetRules || [] - })); - } - - function buildObjectMap() { - normalizeState(); - return { version: APP_VERSION, exportedAt: new Date().toISOString(), summary: integritySummary(), integrity: integrityScore(), campaigns: state.campaigns.map(c => ({ id:c.id, type:c.type, name:c.name, status:c.status, adGroups:c.adGroups, ads:c.ads, targets:c.targets, searchTerms:c.searchTerms, negatives:c.negatives, budgetRules:c.budgetRules })) }; - } - - function downloadObjectMap() { - downloadText('amazon-ppc-simulator-v3-object-map.json', JSON.stringify(buildObjectMap(), null, 2), 'application/json'); - logAction('export_object_map', 'Object map exported', 'good'); - toast('Object map JSON exported.', 'good'); - } - - function csvParseRows(text) { - const rows = []; - let row = [], cell = '', quoted = false; - String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('').forEach((ch, i, arr) => { - if (ch === '"') { - if (quoted && arr[i + 1] === '"') { cell += '"'; arr[i + 1] = ''; } - else quoted = !quoted; - } else if (ch === ',' && !quoted) { row.push(cell.trim()); cell = ''; } - else if (ch === '\n' && !quoted) { row.push(cell.trim()); if (row.some(Boolean)) rows.push(row); row = []; cell = ''; } - else cell += ch; - }); - row.push(cell.trim()); if (row.some(Boolean)) rows.push(row); - return rows; - } - - function campaignById(s, id) { return s.campaigns.find(c => c.id === id); } - function hasNegative(s, campaignId, value) { - const c = campaignById(s, campaignId); - return c && c.negatives.some(n => n.value.toLowerCase() === value.toLowerCase()); - } - function calc(m) { - const ctr = m.impressions ? m.clicks / m.impressions * 100 : 0; - const cpc = m.clicks ? m.spend / m.clicks : 0; - const acos = m.sales ? m.spend / m.sales * 100 : 0; - const roas = m.spend ? m.sales / m.spend : 0; - const cvr = m.clicks ? m.orders / m.clicks * 100 : 0; - return { ctr, cpc, acos, roas, cvr }; - } - function totalMetrics(camps) { - return camps.reduce((acc, c) => { - Object.keys(acc).forEach(k => acc[k] += Number(c.metrics[k] || 0)); - return acc; - }, { impressions:0, clicks:0, spend:0, sales:0, orders:0 }); - } - function campaignTypeLabel(t) { - if (t === 'SP') return 'Sponsored Products'; - if (t === 'SB') return 'Sponsored Brands'; - if (t === 'SD') return 'Sponsored Display'; - return t; - } - function adTypeTag(t) { return `${t}`; } - function statusHtml(status) { return `● ${status}`; } - function acosClass(v) { return v <= 25 ? 'acos-good' : v <= 40 ? 'acos-mid' : 'acos-bad'; } - function productTitle(asin) { const p = products.find(x => x.asin === asin); return p ? p.title : asin; } - function productShort(asin) { const p = products.find(x => x.asin === asin); return p ? `${p.image} ${p.title}` : asin; } - function safe(str) { return String(str || '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); } - - function toast(message, tone = '') { - state.toasts.unshift({ id: uid('toast'), message, tone }); - state.toasts = state.toasts.slice(0, 4); - render(); - setTimeout(() => { - state.toasts = state.toasts.filter(t => t.message !== message); - render(); - }, 2800); - } - - function logAction(type, detail, quality = '') { - const fb = evaluateAction(type, detail, quality); - state.actionLog.push({ type, detail, time: new Date().toISOString(), quality: fb.tone, feedback: fb.message, trainee: state.traineeName }); - state.feedbackLog.unshift({ ...fb, type, detail, time: new Date().toISOString(), trainee: state.traineeName }); - state.feedbackLog = state.feedbackLog.slice(0, 80); - const active = getActiveScenario(); - if (active) updateScenarioProgress(active); - } - - function evaluateAction(type, detail, quality) { - if (quality && typeof quality === 'object') { - return { tone: quality.tone || 'warn', message: quality.message || 'Action reviewed. Explain the data reason.' }; - } - if (quality) return { tone: quality, message: quality === 'good' ? 'Good operator move. Reason was defensible from the data.' : quality === 'bad' ? 'Risky action. Stop and review before repeating.' : 'Needs review. Add account context before repeating.' }; - const good = ['negative_added','harvest_term','budget_rule_created','campaign_created','export_report','copy_report','simulation_run','bulk_apply','request_report','creative_repair','integrity_repair','export_object_map','scenario_generated','mission_started']; - const warn = ['bid_up','bid_down','placement_change','budget_change','status_change','duplicate_campaign','open_campaign','open_tab','bulk_preview']; - const bad = ['archive_campaign']; - if (good.includes(type)) return { tone:'good', message:'Good training action. Ask the VA to explain why it was safe.' }; - if (warn.includes(type)) return { tone:'warn', message:'Requires account context. Check ACOS, CVR, margin, inventory, and goal first.' }; - if (bad.includes(type)) return { tone:'bad', message:'High-risk action. Archive only after approval and rollback review.' }; - return { tone:'warn', message:'Action logged. Trainer should ask for the reasoning.' }; - } - - - - function findSearchTermInCampaign(c, term) { - const needle = String(term || '').toLowerCase(); - return c && (c.searchTerms || []).find(st => String(st.term || '').toLowerCase() === needle); - } - - function targetAcos(t) { return calc(t || {}).acos; } - - function minimumBudget(type) { - if (type === 'SB') return 30; - if (type === 'SD') return 20; - return 10; - } - - function gradeBidMove(c, t, mult, oldBid, newBid) { - const x = calc(t); - const direction = mult > 1 ? 'increase' : 'decrease'; - if (!t.clicks) return { tone:'warn', message:`Bid ${direction} has no click history. Use launch goal or target benchmark, not performance data.` }; - if (mult > 1) { - if (t.orders === 0 && t.clicks >= 15) return { tone:'bad', message:`Bid up on ${t.value} is risky: ${t.clicks} clicks, no orders, ${fmt.money(t.spend)} spend.` }; - if (x.acos > 40) return { tone:'bad', message:`Bid up conflicts with high ACOS (${fmt.pct(x.acos)}). Lower bid, pause, or review query quality first.` }; - if (x.acos <= 25 && t.orders > 0) return { tone:'good', message:`Bid up is defensible: ${fmt.pct(x.acos)} ACOS with ${t.orders} orders.` }; - return { tone:'warn', message:`Bid up needs margin and objective context. Current ACOS is ${fmt.pct(x.acos)}.` }; - } - if (t.orders === 0 && t.clicks >= 15) return { tone:'good', message:`Bid down is defensible: ${t.clicks} clicks with no orders.` }; - if (x.acos > 40) return { tone:'good', message:`Bid down matches high ACOS control: ${fmt.pct(x.acos)}.` }; - if (x.acos <= 25 && t.orders > 0) return { tone:'warn', message:`Bid down may suppress a winner: ${fmt.pct(x.acos)} ACOS with ${t.orders} orders.` }; - return { tone:'warn', message:`Bid down logged. Check CVR, rank goal, and budget pacing before repeating.` }; - } - - function gradePauseTarget(t) { - const x = calc(t || {}); - if (!t.clicks) return { tone:'warn', message:'Pause has no click history. Confirm this is a structural cleanup, not a performance decision.' }; - if (t.orders === 0 && t.clicks >= 20) return { tone:'good', message:`Pause is defensible: ${t.clicks} clicks, no orders, ${fmt.money(t.spend)} spend.` }; - if (x.acos > 60) return { tone:'good', message:`Pause is defensible due to severe ACOS: ${fmt.pct(x.acos)}.` }; - if (t.orders > 0 && x.acos <= 35) return { tone:'bad', message:`Pause may kill a useful target: ${t.orders} orders at ${fmt.pct(x.acos)} ACOS.` }; - return { tone:'warn', message:`Pause needs review. Current ACOS is ${t.sales ? fmt.pct(x.acos) : 'no sales'}.` }; - } - - function gradeHarvest(st) { - if (!st) return { tone:'warn', message:'Harvest source term was not found in the selected campaign. Confirm this came from the correct report.' }; - if (st.orders > 0 && st.sales > st.spend) return { tone:'good', message:`Harvest is defensible: ${st.orders} orders and ${fmt.money(st.sales)} sales from ${fmt.money(st.spend)} spend.` }; - if (st.orders === 0) return { tone:'bad', message:'Harvesting a zero-order query creates extra waste. Negative or watchlist it instead.' }; - return { tone:'warn', message:'Harvest needs margin and conversion review before exact control.' }; - } - - function gradeNegative(c, st, type, duplicate=false) { - if (duplicate) return { tone:'warn', message:'Duplicate negative detected. Do not add duplicate rows in real bulk work.' }; - if (st && st.orders > 0) return { tone:'bad', message:`Negative blocks a converting query with ${st.orders} orders. Use caution or isolate match type first.` }; - if (st && st.orders === 0 && st.clicks >= 15 && /exact/i.test(type)) return { tone:'good', message:`Negative exact is defensible: ${st.clicks} clicks, ${fmt.money(st.spend)} spend, no orders.` }; - if (/phrase/i.test(type) && String(st?.term || '').trim().split(/\s+/).length <= 2) return { tone:'warn', message:'Phrase negative is broad. Confirm it will not block useful variants.' }; - if (st && st.orders === 0) return { tone:'good', message:'Negative added from zero-sale waste term. Confirm scope matches the query intent.' }; - return { tone:'warn', message:'Negative added without source performance. Confirm match type and scope before using live.' }; - } - - function gradeBudgetChange(c, oldBudget, newBudget) { - if (newBudget <= 0) return { tone:'bad', message:'Budget must be above zero.' }; - const lowInventory = (c.products || []).some(asin => (products.find(p => p.asin === asin) || {}).status === 'Low Inventory'); - const x = calc(c.metrics || {}); - if (newBudget > oldBudget && lowInventory) return { tone:'bad', message:'Budget increase conflicts with low inventory. Check retail readiness before scaling.' }; - if (newBudget > oldBudget && c.metrics.sales && x.acos > 40) return { tone:'bad', message:`Budget increase conflicts with high ACOS (${fmt.pct(x.acos)}). Fix efficiency before scaling.` }; - if (newBudget > oldBudget && c.metrics.sales && x.acos <= 30) return { tone:'good', message:`Budget increase is defensible: ${fmt.pct(x.acos)} ACOS and positive sales.` }; - if (newBudget < minimumBudget(c.type)) return { tone:'warn', message:`Budget is below the simulator's safe ${c.type} training floor (${fmt.money(minimumBudget(c.type))}).` }; - return { tone:'warn', message:'Budget changed. Check pacing, inventory, goal, and recent trend before repeating.' }; - } - - function gradePlacementChange(c) { - const x = calc(c.metrics || {}); - const maxPlacement = Math.max(Number(c.placements.top || 0), Number(c.placements.product || 0), Number(c.placements.rest || 0)); - if (maxPlacement > 100 && x.acos > 40) return { tone:'bad', message:`Placement multiplier is aggressive while ACOS is high (${fmt.pct(x.acos)}).` }; - if (maxPlacement > 100) return { tone:'warn', message:'Placement multiplier is aggressive. Confirm rank goal and margin buffer.' }; - if (c.placements.top > 0 && x.acos <= 30 && c.metrics.orders > 0) return { tone:'good', message:'Placement adjustment is reasonable for a campaign with efficient sales.' }; - return { tone:'warn', message:'Placement changed. Review placement report before using this in a live account.' }; - } - - function getActiveScenario() { return allScenarios().find(s => s.id === state.activeScenarioId); } - function scenarioProgress(sc) { - if (!sc) return { done:0, total:0, pct:0, list:[] }; - const list = (sc.objectives || []).map(o => ({ ...o, done: !!o.check(state) })); - const done = list.filter(x => x.done).length; - return { done, total: list.length, pct: list.length ? Math.round(done / list.length * 100) : 0, list }; - } - function updateScenarioProgress(sc) { - const p = scenarioProgress(sc); - if (p.done === p.total && p.total) setTimeout(() => toast(`Mission complete: ${sc.title}. Score ${p.pct}%. Nice operator energy.`, 'good'), 50); - } - - function setView(view) { - state.view = view; - if (view !== 'campaigns') state.selectedCampaignId = ''; - if (view === 'campaigns' && !state.selectedTab) state.selectedTab = 'campaigns'; - render(); - } - function selectCampaign(id) { state.selectedCampaignId = id; state.selectedTab = 'overview'; state.view = 'campaigns'; logAction('open_campaign', id); render(); } - function setSelectedTab(tab) { state.selectedTab = tab; logAction('open_tab', tab); render(); } - - function filteredCampaigns() { - return state.campaigns.filter(c => { - const typeOk = state.filterType === 'All' || c.type === state.filterType; - const statusOk = state.filterStatus === 'All' || c.status === state.filterStatus; - const q = state.search.trim().toLowerCase(); - const text = `${c.name} ${c.type} ${c.targetingMode} ${c.portfolio} ${c.adFormat}`.toLowerCase(); - return typeOk && statusOk && (!q || text.includes(q)); - }); - } - - function render() { - normalizeState(); - persistState(); - const root = $('#root'); - root.innerHTML = ` -
- ${renderTopbar()} - ${renderSidebar()} -
${renderMain()}
- - ${renderToasts()} -
- `; - bindEvents(); - } - - function renderTopbar() { - return ` -
-
-
PPC
-
- Ads Console Training Simulator - Unofficial sandbox for VA navigation and operations practice -
-
-
- V${APP_VERSION} relationship-safe sandbox - No Seller Central access required - - -
-
`; - } - - function navItem(view, icon, label) { - const active = state.view === view ? 'active' : ''; - return ``; - } - function renderSidebar() { - return ` - `; - } - - function renderMain() { - switch (state.view) { - case 'dashboard': return renderDashboard(); - case 'campaigns': return renderCampaignManager(); - case 'create': return renderCreateWizard(); - case 'reports': return renderReports(); - case 'bulk': return renderBulkOps(); - case 'trainer': return renderTrainerDashboard(); - case 'integrity': return renderIntegrityCenter(); - case 'docs': return renderDocumentation(); - case 'products': return renderProducts(); - case 'creative': return renderCreative(); - case 'stores': return renderStores(); - case 'missions': return renderMissionsPage(); - case 'navigation': return renderNavigationMap(); - case 'glossary': return renderGlossary(); - case 'settings': return renderSettings(); - default: return renderDashboard(); - } - } - - function pageTitle(title, subtitle, action = '') { - return `

${title}

${subtitle}

${action}
`; - } - - function renderDashboard() { - const camps = filteredCampaigns(); - const m = totalMetrics(state.campaigns.filter(c => c.status === 'Enabled')); - const c = calc(m); - return ` - ${pageTitle('Home dashboard', 'Train VAs on campaign navigation, setup, and daily management without giving live account access.', '')} -
- ${metricCard('Spend', fmt.money(m.spend), 'Training account, enabled campaigns')} - ${metricCard('Sales', fmt.money(m.sales), 'Attributed sales in simulator', 'good')} - ${metricCard('ACOS', fmt.pct(c.acos), c.acos <= 30 ? 'Healthy blended ACOS' : 'Needs optimization', c.acos <= 30 ? 'good' : 'bad')} - ${metricCard('ROAS', fmt.roas(c.roas), 'Sales divided by ad spend')} -
-
-
-

Campaign snapshot

${state.campaigns.length} campaigns, integrity ${state.lastIntegrityScore}%
- ${renderCampaignTable(state.campaigns.slice(0, 7), true)} -
-
-
-

Operator alerts

Generated from simulated data
-
- ${insight('red','Waste detected','SP Auto has search terms with spend and zero orders. Open Search terms and add negatives.')} - ${insight('orange','SB creative review','Paused SB Video campaign is ready for a relaunch exercise after creative check.')} - ${insight('green','Remarketing winner','SD Views Remarketing has strong ROAS. Good campaign for budget rule practice.')} - ${insight('blue','Navigation drill','Use the left menu: Campaign manager → campaign row → Search terms → action buttons.')} -
-
-
-

Training coverage

Core modules
-
- Sponsored Products - Sponsored Brands - Sponsored Display - Search term harvesting - Negatives - Budget rules - Placement controls - Creative checks -
-
-
-
`; - } - - function metricCard(label, value, delta, tone = '') { - return `
${label}
${value}
${delta}
`; - } - function insight(color, title, text) { - return `

${title}

${text}

`; - } - - function renderCampaignManager() { - const selected = state.selectedCampaignId ? campaignById(state, state.selectedCampaignId) : null; - if (selected) return renderCampaignDetail(selected); - const camps = filteredCampaigns(); - const m = totalMetrics(camps); - const c = calc(m); - return ` - ${pageTitle('Campaign manager', 'Practice the core ads console flow: filter, inspect, optimize, create, and report.', '')} - ${renderToolbar()} -
- ${metricCard('Impressions', fmt.whole(m.impressions), 'Filtered campaigns')} - ${metricCard('Clicks', fmt.whole(m.clicks), 'CTR ' + fmt.pct(c.ctr))} - ${metricCard('Spend', fmt.money(m.spend), 'CPC ' + fmt.bid(c.cpc))} - ${metricCard('ACOS', fmt.pct(c.acos), 'ROAS ' + fmt.roas(c.roas), c.acos <= 30 ? 'good' : 'bad')} -
-
- ${tabButton('campaigns','Campaigns')} - ${tabButton('adgroups','Ad groups')} - ${tabButton('targets','Targeting')} - ${tabButton('searchTerms','Search terms')} - ${tabButton('negatives','Negative targeting')} - ${tabButton('budgetRules','Budget rules')} - ${tabButton('placements','Placements')} - ${tabButton('history','Change history')} -
- ${renderManagerTab(camps)} - `; - } - - function renderToolbar() { - return ` -
- - - - - - -
`; - } - - function tabButton(tab, label) { - return ``; - } - - function renderManagerTab(camps) { - switch (state.selectedTab) { - case 'campaigns': return renderCampaignTable(camps, false); - case 'adgroups': return renderAdGroupTable(camps); - case 'targets': return renderTargetsTable(camps); - case 'searchTerms': return renderSearchTermsTable(camps); - case 'negatives': return renderNegativesTable(camps); - case 'budgetRules': return renderBudgetRulesTable(camps); - case 'placements': return renderPlacementsTable(camps); - case 'history': return renderHistoryTable(camps); - default: return renderCampaignTable(camps, false); - } - } - - function renderCampaignTable(camps, compact) { - if (!camps.length) return `

No campaigns found

Adjust filters or create a new campaign.

`; - return `
- - ${compact ? '' : ''} - - ${camps.map(c => { - const x = calc(c.metrics); - return ` - - - - - - - - - - - ${compact ? '' : ``} - `; - }).join('')} -
CampaignTypeStatusBudgetTargetingImpr.ClicksSpendSalesACOSActions
${safe(c.portfolio)} · ${safe(c.adFormat)}
${adTypeTag(c.type)} ${campaignTypeLabel(c.type)}${statusHtml(c.status)}${fmt.money(c.dailyBudget)}${safe(c.targetingMode)}${fmt.whole(c.metrics.impressions)}${fmt.whole(c.metrics.clicks)}${fmt.money(c.metrics.spend)}${fmt.money(c.metrics.sales)}${fmt.pct(x.acos)}
`; - } - - function renderAdGroupTable(camps) { - const rows = camps.flatMap(c => c.adGroups.map(ag => ({ c, ag }))); - return `
- ${rows.map(({c, ag}) => ``).join('')} -
Ad groupCampaignTypeStatusDefault bidTargetsProducts
${safe(ag.name)}${adTypeTag(c.type)}${statusHtml(ag.status)}${fmt.bid(ag.defaultBid)}${c.targets.length}${c.products.length}
`; - } - - function renderTargetsTable(camps) { - const rows = camps.flatMap(c => c.targets.map(t => ({ c, t }))); - return `
- ${rows.map(({c,t}) => { const x = calc(t); return ` - - - - `; }).join('')} -
TargetCampaignTypeMatch/TacticStatusBidClicksSpendSalesACOSActions
${safe(t.value)}${safe(t.type)}${safe(t.match)}${statusHtml(t.status)}${fmt.bid(t.bid)}${fmt.whole(t.clicks)}${fmt.money(t.spend)}${fmt.money(t.sales)}${t.sales ? fmt.pct(x.acos) : 'No sales'}
`; - } - - function renderSearchTermsTable(camps) { - const rows = camps.flatMap(c => (c.searchTerms || []).map(st => ({ c, st }))); - if (!rows.length) return `

No search term rows

Search term reports are available for SP and SB keyword/product workflows in this simulator.

`; - return `
- ${rows.map(({c,st}) => { const x = calc({ impressions:0, clicks:st.clicks, spend:st.spend, sales:st.sales, orders:st.orders }); return ` - - - - - `; }).join('')} -
Customer search termCampaignMatched targetClicksSpendSalesOrdersACOSRecommendationActions
${safe(st.term)}${safe(st.target)}${fmt.whole(st.clicks)}${fmt.money(st.spend)}${fmt.money(st.sales)}${fmt.whole(st.orders)}${st.sales ? fmt.pct(x.acos) : 'No sales'}${safe(st.recommendation)}
`; - } - - function renderNegativesTable(camps) { - const rows = camps.flatMap(c => (c.negatives || []).map(n => ({ c, n }))); - return `
-

Add negative targeting

Use for training exact, phrase, and product negatives
-
-
-
-
-
- -
-
- ${rows.length ? rows.map(({c,n}) => ``).join('') : ``} -
NegativeTypeCampaignAd type
${safe(n.value)}${safe(n.type)}${adTypeTag(c.type)}
No negatives yet.
`; - } - - function renderBudgetRulesTable(camps) { - const rows = camps.flatMap(c => (c.budgetRules || []).map(r => ({ c, r }))); - return `
-

Create budget rule

Practice schedule and performance-based rule setup
-
-
-
-
-
-
- -
-
- ${rows.length ? rows.map(({c,r}) => ``).join('') : ``} -
RuleCampaignTypeIncreaseCondition
${safe(r.name)}${safe(r.type)}${r.increase}%${safe(r.condition)}
No budget rules yet.
`; - } - - function renderPlacementsTable(camps) { - return `
- ${camps.map(c => ` - - - - - - `).join('')} -
CampaignTypeTop of SearchProduct pagesRest of SearchActions
${adTypeTag(c.type)}%%%
`; - } - - function renderHistoryTable(camps) { - const rows = camps.flatMap(c => (c.history || []).map(h => ({ c, h }))); - return `
- ${rows.map(({c,h}, i) => ``).join('')} - ${state.actionLog.slice().reverse().map(a => ``).join('')} -
TimestampCampaignChangeOperator
${fmt.date(new Date(Date.now() - i * 86400000))}${safe(h)}Training VA
${fmt.date(a.time)}Simulator action${safe(a.type)}: ${safe(a.detail)}You
`; - } - - function renderCampaignDetail(c) { - const m = c.metrics, x = calc(m); - return ` - -
-
-
-

${safe(c.name)}

-
- ${adTypeTag(c.type)} ${statusHtml(c.status)} ${safe(c.targetingMode)}${safe(c.adFormat)}Budget ${fmt.money(c.dailyBudget)}${safe(c.bidStrategy)} -
-
-
- - - -
-
-
-
- ${metricCard('Spend', fmt.money(m.spend), 'CPC ' + fmt.bid(x.cpc))} - ${metricCard('Sales', fmt.money(m.sales), fmt.whole(m.orders) + ' orders', 'good')} - ${metricCard('ACOS', fmt.pct(x.acos), 'Target training benchmark: 30%', x.acos <= 30 ? 'good' : 'bad')} - ${metricCard('CTR', fmt.pct(x.ctr), 'CVR ' + fmt.pct(x.cvr))} -
-
- ${detailTab('overview','Overview')} - ${detailTab('adgroups','Ad groups')} - ${detailTab('targets','Targeting')} - ${detailTab('searchTerms','Search terms')} - ${detailTab('negatives','Negative targeting')} - ${detailTab('budgetRules','Budget rules')} - ${detailTab('placements','Placements')} - ${detailTab('history','Change history')} -
- ${renderCampaignDetailTab(c)} - `; - } - function detailTab(tab, label) { return ``; } - function renderCampaignDetailTab(c) { - switch (state.selectedTab) { - case 'overview': return renderCampaignOverview(c); - case 'adgroups': return renderAdGroupTable([c]); - case 'targets': return renderTargetsTable([c]); - case 'searchTerms': return renderSearchTermsTable([c]); - case 'negatives': return renderNegativesTable([c]); - case 'budgetRules': return renderBudgetRulesTable([c]); - case 'placements': return renderPlacementsTable([c]); - case 'history': return renderHistoryTable([c]); - default: return renderCampaignOverview(c); - } - } - function renderCampaignOverview(c) { - const targetRows = c.targets.slice().sort((a,b) => (b.sales - b.spend) - (a.sales - a.spend)).slice(0,4); - return `
-
-

Campaign settings

Editable training controls
-
-
-
-
-
-
- -
-
-

Products and creative

${campaignTypeLabel(c.type)}
-
${c.products.map(a => `${safe(productShort(a))}`).join('')}
- ${c.creative ? `
-
Brand${safe(c.creative.brandName || 'N/A')}
-
Headline${safe(c.creative.headline || 'N/A')}
-
Destination${safe(c.creative.destination || 'N/A')}
-
Creative${safe(c.creative.video || c.creative.image || 'Auto')}
-
Approval${safe(c.creativeStatus || 'Approved')}
- ${c.creativeIssue ? `
Issue${safe(c.creativeIssue)}
` : ''} -
- ${c.creativeStatus === 'Rejected' ? `` : ''}` : '

Sponsored Products standard campaigns use product listing content.

'} -
-
-

Top targets by profit signal

Use to train bid optimization
- ${renderTargetsMini(targetRows, c)} -
-
`; - } - function renderTargetsMini(rows, c) { - return `
- ${rows.map(t => { const x = calc(t); const suggestion = t.sales === 0 ? 'Pause or bid down' : x.acos < 25 ? 'Bid up or isolate' : x.acos > 45 ? 'Bid down' : 'Monitor'; return ``; }).join('')} -
TargetMatchBidSpendSalesACOSSuggested move
${safe(t.value)}${safe(t.match)}${fmt.bid(t.bid)}${fmt.money(t.spend)}${fmt.money(t.sales)}${t.sales ? fmt.pct(x.acos) : 'No sales'}${suggestion}
`; - } - - function renderCreateWizard() { - const d = state.draft; - return ` - ${pageTitle('Create campaign', 'Build SP, SB, and SD campaigns in a safe launch wizard. The simulator validates common PPC setup mistakes before launch.', '')} -
-
- ${['Ad type','Basics','Products and creative','Targeting','Bidding and budget','Review'].map((x,i) => `
${state.wizardStep>i+1?'✓':i+1}
${x}
`).join('')} -
-
- ${renderWizardStep(d)} -
- -
- - ${state.wizardStep < 6 ? '' : ''} -
-
-
-
`; - } - - function renderWizardStep(d) { - switch (state.wizardStep) { - case 1: return renderStepAdType(d); - case 2: return renderStepBasics(d); - case 3: return renderStepProductsCreative(d); - case 4: return renderStepTargeting(d); - case 5: return renderStepBidding(d); - case 6: return renderStepReview(d); - default: return renderStepAdType(d); - } - } - - function renderStepAdType(d) { - return `

Choose campaign type

The simulator includes the three core ad types a VA will touch in campaign setup and management.

-
- ${adChoice('SP','Sponsored Products','Promote individual listings. Train automatic targeting, manual keyword targeting, product targeting, search term reports, bids, negatives, and placements.', 'Best first PPC operations module')} - ${adChoice('SB','Sponsored Brands','Promote brand creative, product collections, Store spotlight, or video. Train headlines, brand assets, destinations, and keyword/product/category targeting.', 'Brand registered workflow')} - ${adChoice('SD','Sponsored Display','Reach shoppers through contextual targeting or audiences such as views remarketing. Train display setup, creative, products, and audience logic.', 'Upper funnel and remarketing')} -
`; - } - function adChoice(type, title, desc, foot) { - return `
-
${adTypeTag(type)}

${title}

${desc}

${foot} -
`; - } - - function renderStepBasics(d) { - return `

Campaign basics

Set campaign-level controls. This is where many VA errors happen: wrong naming, low budget, wrong status, or wrong format.

-
-
-
-
-
-
-
Training rule: SP/SB at least $25, SD at least $20.
-
${renderFormatSelect(d)}
-
`; - } - function renderFormatSelect(d) { - const opts = d.type === 'SP' ? ['Standard','Video'] : d.type === 'SB' ? ['Product collection','Store spotlight','Video'] : ['Auto generated','Custom image','Video creative']; - return ``; - } - - function renderStepProductsCreative(d) { - const cards = products.map(p => ``).join(''); - const creative = d.type === 'SP' ? `
Sponsored Products standard campaigns use the product detail page as the ad creative. For SP Video, use the ad format selector and treat video as an optional training module.
` : ` -
-

${d.type === 'SB' ? 'Sponsored Brands creative' : 'Sponsored Display creative'}

Training validation enabled
-
-
-
-
-
-
-
-
`; - return `

Products and creative

Select advertised products and provide creative inputs for SB and SD. This trains retail-readiness and brand asset checks.

-
${cards}
${creative}`; - } - - function renderStepTargeting(d) { - const targetingOptions = d.type === 'SP' - ? ['Automatic','Manual keyword','Manual product'] - : d.type === 'SB' - ? ['Keyword','Product','Category'] - : ['Contextual','Audiences - views remarketing','Audiences - purchases remarketing']; - return `

Targeting

Choose how the simulator serves ads. The input fields change by ad type and targeting mode.

-
-
-
- ${renderTargetingInputs(d)} -
`; - } - - function renderTargetingInputs(d) { - if (d.type === 'SP' && d.targetingMode === 'Automatic') { - return `
Automatic SP targeting creates close match, loose match, substitutes, and complements in the simulator. Use this to train discovery and search term mining.
`; - } - if ((d.type === 'SP' && d.targetingMode === 'Manual keyword') || (d.type === 'SB' && d.targetingMode === 'Keyword')) { - return `
`; - } - if ((d.type === 'SP' && d.targetingMode === 'Manual product') || (d.type === 'SB' && d.targetingMode === 'Product') || d.targetingMode === 'Contextual') { - return `
`; - } - if ((d.type === 'SB' && d.targetingMode === 'Category')) { - return `
`; - } - if (d.type === 'SD') { - return `
Examples: Viewed advertised products 30 days, Viewed similar products 14 days, Purchasers 365 days.
`; - } - return ''; - } - - function renderStepBidding(d) { - const strategies = d.type === 'SP' ? ['Dynamic bids - down only','Dynamic bids - up and down','Fixed bids'] : d.type === 'SB' ? ['Cost per click','Cost per thousand impressions'] : ['Cost per click','Cost per thousand impressions']; - return `

Bidding and budget controls

Practice bid strategy and placement adjustments. These fields are intentionally close to daily PPC operations.

-
-
-
-
Use carefully. Great way to train placement math.
-
-
-
-
`; - } - - function validateDraft(d) { - const errors = []; - if (!d.name.trim()) errors.push('Campaign name is required.'); - if (!d.products.length) errors.push('Select at least one product.'); - if (d.type === 'SP' && Number(d.dailyBudget) < 25) errors.push('Sponsored Products training minimum budget is $25.'); - if (d.type === 'SB' && Number(d.dailyBudget) < 25) errors.push('Sponsored Brands training minimum budget is $25.'); - if (d.type === 'SD' && Number(d.dailyBudget) < 20) errors.push('Sponsored Display training minimum budget is $20.'); - if (Number(d.defaultBid) <= 0) errors.push('Default bid must be greater than zero.'); - if ((d.type === 'SP' && d.targetingMode === 'Manual keyword') || (d.type === 'SB' && d.targetingMode === 'Keyword')) { - if (lines(d.keywords).length < 1) errors.push('Add at least one keyword.'); - } - if ((d.type === 'SP' && d.targetingMode === 'Manual product') || (d.type === 'SB' && d.targetingMode === 'Product') || d.targetingMode === 'Contextual') { - if (lines(d.asinTargets).length + lines(d.categoryTargets).length < 1) errors.push('Add at least one ASIN or category target.'); - } - if (d.type === 'SB') { - if (!d.creative.brandName.trim()) errors.push('Sponsored Brands requires a brand name placeholder.'); - if (!d.creative.headline.trim()) errors.push('Sponsored Brands requires a headline.'); - if (d.adFormat !== 'Video' && d.products.length < 3) errors.push('Sponsored Brands Product collection or Store spotlight should use at least three products in this training flow.'); - if (d.adFormat === 'Video' && !d.creative.video.trim()) errors.push('Sponsored Brands Video requires a video asset placeholder.'); - } - if (d.type === 'SD') { - if (!d.creative.headline.trim()) errors.push('Sponsored Display creative needs a headline placeholder in this simulator.'); - if (!d.targetingMode.includes('Contextual') && !d.targetingMode.includes('Audiences')) errors.push('Sponsored Display must use contextual or audience targeting.'); - } - return errors; - } - function lines(text) { return String(text || '').split('\n').map(x => x.trim()).filter(Boolean); } - - function renderStepReview(d) { - const errors = validateDraft(d); - const targets = draftTargets(d); - return `

Review and launch

Review the same items a VA should check before touching a real account. Failed validation is intentional training.

- ${errors.length ? `
Fix before launch:
    ${errors.map(e => `
  • ${safe(e)}
  • `).join('')}
` : `
Validation passed. This campaign is ready to launch in the simulator.
`} -
- ${reviewRow('Ad type', campaignTypeLabel(d.type))} - ${reviewRow('Campaign name', d.name || 'Missing')} - ${reviewRow('Format', d.adFormat)} - ${reviewRow('Targeting', d.targetingMode)} - ${reviewRow('Products', d.products.map(productTitle).join(', '))} - ${reviewRow('Daily budget', fmt.money(d.dailyBudget))} - ${reviewRow('Bid strategy', d.bidStrategy)} - ${reviewRow('Default bid', fmt.bid(d.defaultBid))} - ${reviewRow('Targets', targets.map(t => `${t.value} (${t.match})`).join(', ') || 'Auto targets generated')} - ${d.type !== 'SP' ? reviewRow('Creative', `${d.creative.brandName} · ${d.creative.headline} · ${d.creative.destination}`) : ''} - ${reviewRow('Placement adjustments', `Top ${d.placements.top}%, Product pages ${d.placements.product}%, Rest ${d.placements.rest}%`)} -
`; - } - function reviewRow(k, v) { return `
${k}${safe(v)}
`; } - - function draftTargets(d) { - const bid = Number(d.defaultBid) || 0.5; - if (d.type === 'SP' && d.targetingMode === 'Automatic') { - return ['Close match','Loose match','Substitutes','Complements'].map(x => makeTarget('Auto', x, 'Auto', bid)); - } - if ((d.type === 'SP' && d.targetingMode === 'Manual keyword') || (d.type === 'SB' && d.targetingMode === 'Keyword')) { - return lines(d.keywords).map(k => makeTarget('Keyword', k, d.matchType, bid)); - } - if ((d.type === 'SP' && d.targetingMode === 'Manual product') || (d.type === 'SB' && d.targetingMode === 'Product') || d.targetingMode === 'Contextual') { - const asins = lines(d.asinTargets).map(a => makeTarget('ASIN', a, d.targetingMode === 'Contextual' ? 'Contextual' : 'Product', bid)); - const cats = lines(d.categoryTargets).map(a => makeTarget('Category', a, d.targetingMode === 'Contextual' ? 'Contextual' : 'Category', bid)); - return asins.concat(cats); - } - if (d.type === 'SB' && d.targetingMode === 'Category') return lines(d.categoryTargets).map(a => makeTarget('Category', a, 'Category', bid)); - if (d.type === 'SD') return lines(d.audienceTargets).map(a => makeTarget('Audience', a, d.targetingMode.includes('views') ? 'Remarketing' : d.targetingMode.includes('purchases') ? 'Purchases remarketing' : 'Contextual', bid)); - return []; - } - function makeTarget(type, value, match, bid) { - return { id: uid('T'), type, value, match, bid, status: 'Enabled', impressions:0, clicks:0, spend:0, sales:0, orders:0 }; - } - - function launchCampaign() { - const d = copy(state.draft); - const errors = validateDraft(d); - if (errors.length) { toast('Launch blocked. Fix validation errors first.', 'bad'); render(); return; } - const id = uid('C-' + d.type); - const agId = uid('AG'); - const targets = draftTargets(d).map(t => ({ ...t, campaignId:id, adGroupId:agId })); - const adGroups = [{ id: agId, campaignId:id, name: d.type + ' training ad group', status: d.status, defaultBid: Number(d.defaultBid) }]; - const ads = d.products.map((asin, i) => ({ id: uid('AD'), campaignId:id, adGroupId:agId, asin, status: d.status === 'Archived' ? 'Archived' : 'Enabled', name: `${d.type} product ad ${i + 1}` })); - const c = { - id, type: d.type, name: d.name, portfolio: d.portfolio, status: d.status, dailyBudget: Number(d.dailyBudget), startDate: d.startDate, endDate: d.endDate, - targetingMode: d.targetingMode, adFormat: d.adFormat, bidStrategy: d.bidStrategy, defaultBid: Number(d.defaultBid), products: d.products, - creative: d.type === 'SP' ? null : copy(d.creative), placements: copy(d.placements), budgetRules: [], negatives: [], metrics: { impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 }, - adGroups, ads, targets, - searchTerms: d.type === 'SD' ? [] : seedSearchTerms(d, id, agId, targets), history: ['Campaign launched in simulator'], createdBySimulator: true - }; - normalizeCampaign(c); - state.campaigns.unshift(c); - state.selectedCampaignId = id; - state.selectedTab = 'overview'; - state.view = 'campaigns'; - state.draft = makeDraft(); - state.wizardStep = 1; - logAction('campaign_created', `${c.type} ${c.name}`, 'good'); - toast(`${campaignTypeLabel(c.type)} campaign launched in training sandbox.`, 'good'); - } - function seedSearchTerms(d, campaignId = '', adGroupId = '', targets = []) { - const base = d.type === 'SB' ? ['coffee accessories gift', 'home coffee bar accessories'] : ['stainless coffee filter', 'paper coffee filters bulk']; - return base.map((term, i) => { - const t = targets[i] || targets[0] || {}; - return { id: uid('ST'), campaignId, adGroupId, targetId:t.id || '', term, target: t.value || d.targetingMode, clicks: i ? 18 : 32, spend: i ? 16.2 : 24.64, sales: i ? 0 : 89.95, orders: i ? 0 : 4, recommendation: i ? 'Add negative exact' : 'Harvest as exact keyword' }; - }); - } - - function renderReports() { - const rows = reportRowsFor(state.reportType); - return `${pageTitle('Reports', 'Practice report requests, download history, and action decisions without touching live data.', ' ')} -
- - - - -
-
-

Report trainer

${safe(state.reportType)}

Teach VAs to pick the correct report before choosing an action. SP and SB search term work uses customer query rows. SD work uses targeting, audience, campaign, and product-level rows.

${reviewRow('Date range', state.dateRange)}${reviewRow('Rows generated', rows.length)}${reviewRow('Relationship source', reportSourceLabel(state.reportType))}
-

Report center queue

${state.reportQueue.length} requests
${renderReportQueue()}
-
- ${renderReportTable(state.reportType, rows)}`; - } - - function reportSourceLabel(type) { - if (type === 'Search term report') return 'campaign.searchTerms linked to targetId'; - if (type === 'Targeting report') return 'campaign.targets linked to adGroupId'; - if (type === 'Advertised product report') return 'campaign.ads linked to products'; - if (type === 'Budget report') return 'campaign.budgetRules and dailyBudget'; - return 'campaign-level metrics'; - } - - function reportRowsFor(type) { - normalizeState(); - if (type === 'Search term report') return state.campaigns.flatMap(c => (c.searchTerms || []).map(st => { - const t = c.targets.find(x => x.id === st.targetId) || {}; - return { campaignId:c.id, campaign:c.name, adType:c.type, adGroupId:st.adGroupId, targetId:st.targetId || '', target:t.value || st.target || '', searchTerm:st.term, clicks:st.clicks, spend:st.spend, sales:st.sales, orders:st.orders, acos: st.sales ? calc(st).acos : 0, recommendation:st.recommendation }; - })); - if (type === 'Targeting report') return state.campaigns.flatMap(c => (c.targets || []).map(t => ({ campaignId:c.id, campaign:c.name, adType:c.type, adGroupId:t.adGroupId, targetId:t.id, target:t.value, match:t.match, status:t.status, bid:t.bid, clicks:t.clicks, spend:t.spend, sales:t.sales, orders:t.orders, acos:t.sales ? calc(t).acos : 0 }))); - if (type === 'Advertised product report') return state.campaigns.flatMap(c => (c.ads || []).map(ad => { const p = products.find(x => x.asin === ad.asin) || {}; const share = Math.max(1, (c.ads || []).length); return { campaignId:c.id, campaign:c.name, adType:c.type, adGroupId:ad.adGroupId, adId:ad.id, asin:ad.asin, sku:p.sku || '', product:p.title || ad.asin, status:ad.status, spend:c.metrics.spend / share, sales:c.metrics.sales / share, orders:Math.round(c.metrics.orders / share), retailStatus:p.status || 'Unknown' }; })); - if (type === 'Purchased product report') return state.campaigns.flatMap(c => (c.searchTerms || []).filter(st => st.orders > 0).map(st => { const asin = c.products[0]; return { campaignId:c.id, campaign:c.name, adType:c.type, searchTerm:st.term, advertisedAsin:asin, purchasedAsin:asin, orders:st.orders, sales:st.sales, sameAsAdvertised:'Yes', action:'Harvest or protect winner' }; })); - if (type === 'Budget report') return state.campaigns.map(c => ({ campaignId:c.id, campaign:c.name, adType:c.type, status:c.status, dailyBudget:c.dailyBudget, sevenDayCapacity:c.dailyBudget * 7, spend:c.metrics.spend, budgetRules:(c.budgetRules || []).length, ruleNames:(c.budgetRules || []).map(r => r.name).join('; ') || 'None', recommendation:c.metrics.spend > c.dailyBudget * 25 ? 'Check budget cap and pacing' : 'Monitor' })); - if (type === 'Campaign placement report') return state.campaigns.map(c => ({ campaignId:c.id, campaign:c.name, adType:c.type, topOfSearch:c.placements.top, productPages:c.placements.product, restOfSearch:c.placements.rest, spend:c.metrics.spend, sales:c.metrics.sales, acos:c.metrics.sales ? calc(c.metrics).acos : 0, recommendation:c.placements.top > 50 ? 'Review Top of Search multiplier' : 'Monitor' })); - return state.campaigns.map(c => { const x = calc(c.metrics); return { campaignId:c.id, campaign:c.name, adType:c.type, status:c.status, portfolio:c.portfolio, budget:c.dailyBudget, impressions:c.metrics.impressions, clicks:c.metrics.clicks, spend:c.metrics.spend, sales:c.metrics.sales, orders:c.metrics.orders, acos:x.acos, roas:x.roas }; }); - } - - function renderReportTable(type, rows) { - if (!rows.length) return `

No rows for ${safe(type)}

Try a different report type or run a simulation.

`; - const keys = Object.keys(rows[0]); - return `
${keys.map(k => ``).join('')} - ${rows.map(row => `${keys.map(k => ``).join('')}`).join('')} -
${safe(labelize(k))}
${formatReportCell(k, row[k])}
`; - } - - function labelize(k) { return String(k).replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase()); } - function formatReportCell(k, v) { - if (k === 'adType') return adTypeTag(v); - if (['spend','sales','dailyBudget','sevenDayCapacity','budget'].includes(k)) return fmt.money(v); - if (['bid'].includes(k)) return fmt.bid(v); - if (['acos'].includes(k)) return v ? `${fmt.pct(v)}` : 'No sales'; - if (['roas'].includes(k)) return fmt.roas(v); - if (['campaign'].includes(k)) { - const row = state.campaigns.find(c => c.name === v); - return row ? `` : safe(v); - } - return safe(v); - } - - function renderReportQueue() { - const rows = state.reportQueue.slice().reverse().slice(0, 5); - if (!rows.length) return `
No report requests yet.
`; - return `
${rows.map(r => `
${safe(r.type)}
${safe(r.status)}${safe(r.dateRange)}${r.rows} rows${safe(r.id)}
`).join('')}
`; - } - - function requestReport() { - const rows = reportRowsFor(state.reportType); - const report = { id: uid('RPT'), type: state.reportType, dateRange: state.dateRange, status: 'Completed', rows: rows.length, createdAt: new Date().toISOString(), source: reportSourceLabel(state.reportType) }; - state.reportQueue.push(report); - state.selectedReportId = report.id; - logAction('request_report', `${report.type} ${report.rows} rows`, 'good'); - toast('Report request completed in simulator.', 'good'); - render(); - } - - - - function trainerMetrics() { - const total = state.actionLog.length; - const good = state.actionLog.filter(a => a.quality === 'good').length; - const warn = state.actionLog.filter(a => a.quality === 'warn').length; - const bad = state.actionLog.filter(a => a.quality === 'bad').length; - const score = total ? Math.max(0, Math.round((good * 100 + warn * 55 - bad * 35) / total)) : 0; - return { total, good, warn, bad, score: Math.min(100, score) }; - } - - function renderTrainerDashboard() { - const tm = trainerMetrics(); - const rows = state.actionLog.slice().reverse().slice(0, 60); - return `${pageTitle('Trainer dashboard', 'Track trainee actions, review mistakes, export proof of practice, and run certification-style sessions.', '')} -
- - - - - -
-
- ${metricCard('Operator score', tm.score + '%', 'Good actions minus risky actions', tm.score >= 75 ? 'good' : 'bad')} - ${metricCard('Good actions', tm.good, 'Validated safe moves', 'good')} - ${metricCard('Review actions', tm.warn, 'Needs trainer follow-up')} - ${metricCard('Risky actions', tm.bad, 'High-risk or wrong-context moves', 'bad')} -
-
-

Action review log

${rows.length} recent actions
-
- ${rows.map(a => ``).join('') || ''} -
TimeActionDetailGradeTrainer prompt
${fmt.date(a.time)}${safe(a.type)}${safe(a.detail)}${safe(a.quality || 'review')}${safe(a.feedback || 'Ask for reasoning.')}
No actions yet. Start a mission or change a campaign.
-
-

Trainer notes

Saved locally
- - -
Certification rule:
Pass only when the trainee completes SP build, SB build, SD build, one optimization mission, one report export, and explains rollback steps.
-
-
`; - } - - function renderBulkOps() { - return `${pageTitle('Bulk operations', 'Practice bulk-sheet thinking with relationship validation. Paste CSV, validate rows, preview changes, then apply to sandbox data.', '')} -
-

Bulk sheet input

CSV training mode
-

V3 supports entity-based rows: Campaign, Target, Negative, Placement, and BudgetRule. Legacy V2 action rows still work for backwards compatibility.

- -
-
-

Validation preview

${state.bulkPreview.length} rows
-
- ${state.bulkPreview.map(r => ``).join('') || ''} -
RowEntityOperationStatusMessage
${r.row}${safe(r.entity || r.action)}${safe(r.operation || '')}${r.valid ? (r.severity === 'warn' ? 'Review' : 'Valid') : 'Error'}${safe(r.message)}
Paste CSV and validate first.
-
-
-

Bulk ops teaching checks

Trainer prompts
-
-

Before upload

Confirm account, campaign IDs, ad group IDs, target IDs, date range, action columns, and rollback copy.

-

During validation

Fix missing IDs, invalid actions, impossible bids, broad negatives, and placement multipliers above safe limits.

-

After apply

Check change history, compare budgets and bids, export trainer log, then run Integrity center.

-
-
`; - } - - - function renderIntegrityCenter() { - const summary = integritySummary(); - const audit = integrityScore(); - const rows = relationshipRows(); - return `${pageTitle('Integrity center', 'V3 relationship QA for campaign objects, child entities, report rows, and training data. This keeps the simulator stable as scenarios grow.', ' ')} -
- ${metricCard('Integrity score', audit.score + '%', `${audit.errors} errors, ${audit.warnings} warnings`, audit.score >= 85 ? 'good' : 'bad')} - ${metricCard('Campaigns', summary.campaigns, 'Parent objects')} - ${metricCard('Child objects', summary.adGroups + summary.ads + summary.targets + summary.searchTerms + summary.negatives + summary.rules, 'Ad groups, ads, targets, terms, negatives, rules')} - ${metricCard('Last self-heal', state.integrityLastRun ? fmt.date(state.integrityLastRun) : 'Not run', 'Local browser state')} -
-
-
-

Integrity checks

${audit.checks.length} findings
-
- ${audit.checks.length ? audit.checks.map(x => ``).join('') : ''} -
SeverityEntityFindingFix
${safe(x.severity)}${safe(x.entity)}${safe(x.message)}${safe(x.fix)}
No integrity findings. The sandbox is clean.
-
-
-

Object counts

Relationship map
-
- ${reviewRow('Campaigns', summary.campaigns)} - ${reviewRow('Ad groups', summary.adGroups)} - ${reviewRow('Product ads', summary.ads)} - ${reviewRow('Targets', summary.targets)} - ${reviewRow('Search terms', summary.searchTerms)} - ${reviewRow('Negatives', summary.negatives)} - ${reviewRow('Budget rules', summary.rules)} -
-
Data model: Campaign → Ad group → Product ad and Target. Search terms link back to targets. Negatives and budget rules link back to campaigns and ad groups. Reports read from this graph instead of separate loose tables.
-
-
-
-

Campaign relationship map

${rows.length} campaigns
-
- ${rows.map(r => `
${adTypeTag(r.campaign.type)} ${safe(r.campaign.name)} -
ID ${safe(r.campaign.id)}Ad groups ${r.adGroups.length}Ads ${r.ads.length}Targets ${r.targets.length}Search terms ${r.searchTerms.length}Negatives ${r.negatives.length}Rules ${r.rules.length}
-
`).join('')} -
-
`; - } - - - function renderDocumentation() { - return `${pageTitle('Documentation', 'Living product notes for the Amazon PPC Training Simulator V3.', '')} -
-

What this simulator trains

  • SP setup, auto/manual targeting, search term harvesting, negatives, placements, budgets, and bid review.
  • SB Product Collection, Store Spotlight, Video, creative checks, destinations, and approval repair.
  • SD contextual targeting, views remarketing, purchases remarketing, creative, audience logic, and non-search-term reporting.
  • Campaign manager navigation, report interpretation, bulk operations, scenario drills, and trainer review.
-

What changed in V3

  • Added normalized relationships between campaigns, ad groups, product ads, targets, search terms, negatives, and budget rules.
  • Added Integrity center with self-heal and object map export.
  • Added randomized scenario generator for beginner, intermediate, and advanced drills.
  • Expanded report center with request queue and more report types.
  • Upgraded bulk operations to entity-based validation with legacy V2 support.
  • Added creative rejection repair for SB training scenarios.
  • Improved report export and copy behavior for the selected report type.
-

Object hierarchy

  • Campaign owns ad groups.
  • Ad group owns product ads and targets.
  • Targets receive search terms when applicable.
  • Negatives and budget rules attach back to campaigns.
  • Reports are generated from those relationships.
-

Recommended training path

  • Day 1: navigation map, glossary, and read-only campaign review.
  • Day 2: build SP Auto and Manual campaigns.
  • Day 3: search term harvesting and negative targeting.
  • Day 4: SB creative, Store destination, and approval repair.
  • Day 5: SD contextual and remarketing setup.
  • Day 6: reports, bulk ops, and Integrity center.
  • Day 7: randomized scenarios and trainer certification.
-

Trainer QA checklist

  • Ask the VA to narrate where they are in the console.
  • Ask for the data reason before any bid, budget, placement, or negative action.
  • Run Integrity center after import or bulk changes.
  • Export trainer log after each session.
  • Use object map export when reviewing broken relationships.
-

Known V3 limits

  • No live Amazon connection.
  • No multi-user backend.
  • No real file upload to Amazon Ads.
  • UI is workflow-inspired, not an exact clone.
  • Metrics are simulated and not predictive.
-
`; - } - - - function renderProducts() { - return `${pageTitle('Products', 'Retail-readiness training. VAs should confirm product status before campaign setup.', '')} -
- ${products.map(p => ``).join('')} -
ProductASINSKUPriceMarginRatingStatusTraining warning
${p.image} ${safe(p.title)}
${safe(p.brand)}
${p.asin}${p.sku}${fmt.money(p.price)}${Math.round(p.margin*100)}%${p.rating} (${p.reviews})${p.status}${p.status === 'Low Inventory' ? 'Avoid scaling' : 'OK for ads'}
`; - } - function renderCreative() { - const creativeCampaigns = state.campaigns.filter(c => c.type !== 'SP' || c.adFormat === 'Video'); - return `${pageTitle('Creative assets', 'SB and SD workflows need creative checks before launch.', '')} -
- ${['Logo placeholder','Lifestyle image','Video asset'].map((x,i) => `

${x}

${i===2?'6 to 45s training check':'Brand asset'}
${i===0?'◈':i===1?'▧':'▶'}

Use as a placeholder for creative approval drills. This simulator does not upload files.

`).join('')} -
-

Campaigns using creative

${creativeCampaigns.length}
${renderCampaignTable(creativeCampaigns, true)}
`; - } - function renderStores() { - return `${pageTitle('Stores', 'Mock Brand Store destination selector for Sponsored Brands training.', '')} -
- ${['Home page','Coffee Accessories','Bundles and Gifts'].map((x,i) => `

${x}

Store page
Products${i===0?5:i===1?4:3}
Ready for SBYes
`).join('')} -
`; - } - - function renderMissionsPage() { - const scenarios = allScenarios(); - return `${pageTitle('Training missions', 'Start a mission or generate fresh account problems. The score updates as VAs complete workflow actions inside the simulator.', '')} -
-

Scenario generator

Creates new sandbox campaigns with built-in traps
-

Use this for repeated VA practice. Each generated scenario adds a realistic campaign object, links child objects correctly, and starts a mission against that data.

-
-
-
- ${scenarios.map(sc => { - const active = sc.id === state.activeScenarioId; - const p = active ? scenarioProgress(sc) : { pct:0, done:0, total: (sc.objectives || []).length }; - return `
${sc.type}${sc.difficulty}${sc.minutes} min${sc.generated ? 'Generated' : ''}

${safe(sc.title)}

${safe(sc.summary)}

Progress${p.done}/${p.total}
Score${p.pct}%
`; - }).join('')} -
`; - } - - - function renderNavigationMap() { - return `${pageTitle('Navigation map', 'Teach VAs where to go before they memorize what to do. This page maps the ads console operating routes.', '')} - -

Recommended VA learning path

From safe to dangerous
-
-

Week 1: Navigation

Open campaigns, use filters, read metrics, identify ad type differences.

-

Week 2: Setup

Build SP auto/manual, SB creative campaigns, and SD contextual or remarketing campaigns.

-

Week 3: Management

Negatives, harvesting, bid changes, budget rules, placements, and report exports.

-
-
`; - } - function mapNode(title, desc, pill) { return `

${title}

${desc}

${pill}
`; } - - function renderGlossary() { - const terms = [ - ['SP','Sponsored Products. Product-level ads used for listing traffic and conversion training.'], - ['SB','Sponsored Brands. Brand creative ads using product collection, Store spotlight, or video.'], - ['SD','Sponsored Display. Display campaigns using contextual or audience-based targeting.'], - ['ACOS','Advertising cost of sales. Spend divided by attributed sales.'], - ['ROAS','Return on ad spend. Sales divided by spend.'], - ['CTR','Click-through rate. Clicks divided by impressions.'], - ['CVR','Conversion rate. Orders divided by clicks.'], - ['CPC','Cost per click. Spend divided by clicks.'], - ['Negative exact','Blocks a specific customer search term.'], - ['Negative phrase','Blocks searches containing the phrase. Handle with care. Tiny grenade, big boom.'], - ['Harvesting','Moving converting search terms into controlled keyword campaigns.'], - ['Placement adjustment','A campaign-level multiplier for Top of Search, product pages, or other placements.'] - ]; - return `${pageTitle('PPC glossary', 'A plain-English reference for trainees while they practice.', '')} -
- ${terms.map(([k,v]) => ``).join('')} -
TermMeaningTraining check
${k}${safe(v)}Ask the VA to explain it before making account changes.
`; - } - function renderSettings() { - return `${pageTitle('Simulator settings', 'Reset data, toggle hints, and control training difficulty.', '')} -
-

Training controls

Local browser state


-

Build notes

Version ${APP_VERSION}

This static simulator saves progress in your browser with LocalStorage. V3 adds relationship-safe data, Integrity center, generated scenarios, report queue, improved bulk validation, and stronger trainer QA.

-
`; - } - - function renderRightRail() { - const sc = getActiveScenario(); - if (!sc) return renderMissionPickerRail(); - const p = scenarioProgress(sc); - return `
-

Active mission

${sc.type}
-
${p.pct}%
-

${safe(sc.title)}

-

${safe(sc.summary)}

-
${p.list.map(o => `
${o.done?'✓':''}
${safe(o.text)}
`).join('')}
- ${state.showHints ? `
${safe(sc.coach)}
` : ''} -
-
-
Trainer prompt:
Ask the VA to narrate each click, explain why the action is safe, and name the rollback step.
`; - } - function renderMissionPickerRail() { - return `

Mission control

No active mission

Start a guided scenario to score VA actions while they use the simulator.

-

Quick drills

5 minute reps
-
Find the highest ACOS campaign.
-
Add one negative exact from a search term report.
-
Create one SB campaign and explain each creative field.
-
Create one SD remarketing campaign and explain why it has no keywords.
-
`; - } - function renderToasts() { - if (!state.toasts.length) return ''; - return `
${state.toasts.map(t => `
${safe(t.message)}
`).join('')}
`; - } - - function bindEvents() { - $$('[data-view]').forEach(el => el.addEventListener('click', () => setView(el.dataset.view))); - $$('[data-campaign]').forEach(el => el.addEventListener('click', () => selectCampaign(el.dataset.campaign))); - $$('[data-tab]').forEach(el => el.addEventListener('click', () => setSelectedTab(el.dataset.tab))); - $$('[data-field]').forEach(el => el.addEventListener('input', () => { state[el.dataset.field] = el.value; render(); })); - $$('[data-draft]').forEach(el => el.addEventListener('input', () => updateDraft(el.dataset.draft, el.value))); - $$('[data-draft-number]').forEach(el => el.addEventListener('input', () => updateDraft(el.dataset.draftNumber, Number(el.value)))); - $$('[data-creative]').forEach(el => el.addEventListener('input', () => { state.draft.creative[el.dataset.creative] = el.value; render(); })); - $$('[data-placement]').forEach(el => el.addEventListener('input', () => { state.draft.placements[el.dataset.placement] = Number(el.value); })); - $$('[data-draft-type]').forEach(el => el.addEventListener('click', () => chooseType(el.dataset.draftType))); - $$('[data-product-check]').forEach(el => el.addEventListener('change', () => toggleProduct(el.dataset.productCheck, el.checked))); - $$('[data-action]').forEach(el => el.addEventListener('click', e => handleAction(e, el.dataset.action, el))); - $$('#hintToggle').forEach(el => el.addEventListener('change', () => { state.showHints = el.checked; render(); })); - $$('#stateImport').forEach(el => el.addEventListener('change', importState)); - } - - function updateDraft(field, value) { - state.draft[field] = value; - if (field === 'targetingMode') { - if (state.draft.type === 'SD' && value.includes('purchases')) state.draft.audienceTargets = 'Purchased advertised products, 365 days'; - if (state.draft.type === 'SD' && value.includes('views')) state.draft.audienceTargets = 'Viewed advertised products, 30 days\nViewed similar products, 14 days'; - if (state.draft.type === 'SD' && value === 'Contextual') state.draft.audienceTargets = 'Coffee Organizers\nEspresso Accessories'; - } - render(); - } - function chooseType(type) { - const d = state.draft; - d.type = type; - d.name = `${type} | ${type === 'SP' ? 'Manual' : type === 'SB' ? 'Product Collection' : 'Views Remarketing'} | Coffee Accessories | Training`; - d.targetingMode = type === 'SP' ? 'Manual keyword' : type === 'SB' ? 'Keyword' : 'Audiences - views remarketing'; - d.adFormat = type === 'SP' ? 'Standard' : type === 'SB' ? 'Product collection' : 'Auto generated'; - d.bidStrategy = type === 'SP' ? 'Dynamic bids - down only' : 'Cost per click'; - d.dailyBudget = type === 'SD' ? 20 : 25; - render(); - } - function toggleProduct(asin, checked) { - const set = new Set(state.draft.products); - checked ? set.add(asin) : set.delete(asin); - state.draft.products = Array.from(set); - render(); - } - - function handleAction(e, action, el) { - e.stopPropagation(); - switch(action) { - case 'newCampaign': state.view = 'create'; state.selectedCampaignId = ''; render(); break; - case 'resetFilters': state.filterType='All'; state.filterStatus='All'; state.search=''; render(); break; - case 'simulateDays': simulateDays(); break; - case 'backToCampaigns': state.selectedCampaignId=''; state.selectedTab='campaigns'; render(); break; - case 'toggleStatus': toggleStatus(el.dataset.id); break; - case 'archiveCampaign': archiveCampaign(el.dataset.id); break; - case 'duplicateCampaign': duplicateCampaign(el.dataset.id); break; - case 'bidDown': adjustTargetBid(el.dataset.cid, el.dataset.tid, 0.9); break; - case 'bidUp': adjustTargetBid(el.dataset.cid, el.dataset.tid, 1.1); break; - case 'pauseTarget': pauseTarget(el.dataset.cid, el.dataset.tid); break; - case 'harvest': harvestTerm(el.dataset.cid, el.dataset.term); break; - case 'negative': addNegative(el.dataset.cid, el.dataset.term, 'Negative exact'); break; - case 'manualNegative': manualNegative(); break; - case 'createBudgetRule': createBudgetRule(); break; - case 'savePlacements': savePlacements(el.dataset.id); break; - case 'saveCampaignSettings': saveCampaignSettings(el.dataset.id); break; - case 'repairCreative': repairCreative(el.dataset.id); break; - case 'prevStep': if (state.wizardStep > 1) state.wizardStep--; render(); break; - case 'nextStep': if (state.wizardStep < 6) state.wizardStep++; render(); break; - case 'resetDraft': state.draft = makeDraft(); state.wizardStep = 1; render(); break; - case 'launchCampaign': launchCampaign(); break; - case 'requestReport': requestReport(); break; - case 'exportReport': exportCsv(); break; - case 'copyReport': copyReport(); break; - case 'saveTrainee': saveTrainee(); break; - case 'saveTrainerNotes': saveTrainerNotes(); break; - case 'exportTrainerLog': exportTrainerLog(); break; - case 'exportState': exportState(); break; - case 'clearProgress': clearProgress(); break; - case 'previewBulk': previewBulk(); break; - case 'applyBulk': applyBulk(); break; - case 'downloadBulkTemplate': downloadBulkTemplate(); break; - case 'runIntegrity': autoRepairState(); break; - case 'downloadObjectMap': downloadObjectMap(); break; - case 'exportDocs': exportDocs(); break; - case 'generateScenario': generateScenario(el.dataset.difficulty || state.scenarioDifficulty); break; - case 'startMission': startMission(el.dataset.id); break; - case 'goMissionStart': goMissionStart(); break; - case 'stopMission': state.activeScenarioId=''; render(); break; - case 'resetAll': resetAll(); break; - } - } - - function toggleStatus(id) { - const c = campaignById(state, id); - if (!c || c.status === 'Archived') return; - const next = c.status === 'Enabled' ? 'Paused' : 'Enabled'; - if (next === 'Enabled' && c.type !== 'SP' && c.creativeStatus === 'Rejected') { - logAction('status_change', `${c.name} blocked due to rejected creative`, { tone:'bad', message:'Cannot safely enable a rejected creative. Fix approval issue first.' }); - toast('Enable blocked. Fix rejected creative first.', 'bad'); - render(); - return; - } - c.status = next; - c.adGroups.forEach(ag => ag.status = next); - c.ads.forEach(ad => ad.status = next); - c.targets.forEach(t => t.status = next); - c.history.push(`Status changed to ${c.status}`); - const lowInventory = next === 'Enabled' && (c.products || []).some(asin => (products.find(p => p.asin === asin) || {}).status === 'Low Inventory'); - logAction('status_change', `${c.name} ${c.status}`, lowInventory ? { tone:'warn', message:'Campaign enabled with a low-inventory product. Confirm retail readiness before scaling.' } : 'good'); - toast(`${c.name} is now ${c.status}.`, lowInventory ? 'warn' : 'good'); - render(); - } - function archiveCampaign(id) { - const c = campaignById(state, id); if (!c) return; - c.status = 'Archived'; - c.adGroups.forEach(ag => ag.status = 'Archived'); - c.ads.forEach(ad => ad.status = 'Archived'); - c.targets.forEach(t => t.status = 'Archived'); - c.history.push('Campaign archived'); - normalizeCampaign(c); - logAction('archive_campaign', c.name); - toast('Campaign archived in simulator.', 'warn'); render(); - } - function duplicateCampaign(id) { - const c = campaignById(state, id); if (!c) return; - normalizeCampaign(c); - const n = copy(c); - const newId = uid('C-' + n.type); - const adGroupMap = new Map(); - const targetMap = new Map(); - n.id = newId; - n.name += ' copy'; - n.status = 'Paused'; - n.createdBySimulator = true; - n.metrics = { impressions:0, clicks:0, spend:0, sales:0, orders:0 }; - n.history = [`Duplicated from ${c.id} in simulator`, 'Historical report rows cleared for clean training copy']; - n.adGroups = (n.adGroups || []).map(ag => { const newAg = uid('AG'); adGroupMap.set(ag.id, newAg); return { ...ag, id:newAg, campaignId:newId, status:'Paused' }; }); - if (!n.adGroups.length) n.adGroups = [{ id:uid('AG'), campaignId:newId, name:`${n.type} copied ad group`, status:'Paused', defaultBid:n.defaultBid }]; - const primaryAg = n.adGroups[0].id; - n.ads = (n.ads || []).map(ad => ({ ...ad, id:uid('AD'), campaignId:newId, adGroupId:adGroupMap.get(ad.adGroupId) || primaryAg, status:'Paused' })); - n.targets = (n.targets || []).map(t => { const newT = uid('T'); targetMap.set(t.id, newT); return { ...t, id:newT, campaignId:newId, adGroupId:adGroupMap.get(t.adGroupId) || primaryAg, status:'Paused', impressions:0, clicks:0, spend:0, sales:0, orders:0 }; }); - n.searchTerms = []; - n.negatives = (n.negatives || []).map(neg => ({ ...neg, id:uid('NEG'), campaignId:newId, adGroupId:adGroupMap.get(neg.adGroupId) || primaryAg, sourceSearchTermId:'' })); - n.budgetRules = (n.budgetRules || []).map(r => ({ ...r, id:uid('BR'), campaignId:newId })); - normalizeCampaign(n); - state.campaigns.unshift(n); - logAction('duplicate_campaign', `${c.name} copied to ${n.id}`, { tone:'good', message:'Duplicate created as paused clean copy with child relationships re-keyed.' }); - toast('Campaign duplicated as paused copy with clean relationships.', 'good'); render(); - } - function adjustTargetBid(cid, tid, mult) { - const c = campaignById(state, cid); const t = c && c.targets.find(x => x.id === tid); if (!t) return; - const oldBid = Number(t.bid || 0); - t.bid = Math.max(0.02, Math.round(t.bid * mult * 100) / 100); - c.history.push(`Bid updated for ${t.value} from ${fmt.bid(oldBid)} to ${fmt.bid(t.bid)}`); - const q = gradeBidMove(c, t, mult, oldBid, t.bid); - logAction(mult > 1 ? 'bid_up' : 'bid_down', `${t.value} ${fmt.bid(oldBid)} to ${fmt.bid(t.bid)} ACOS ${t.sales ? fmt.pct(calc(t).acos) : 'no sales'}`, q); - toast(`Bid updated for ${t.value}: ${fmt.bid(t.bid)}.`, q.tone === 'bad' ? 'bad' : q.tone === 'warn' ? 'warn' : 'good'); render(); - } - function pauseTarget(cid, tid) { - const c = campaignById(state, cid); const t = c && c.targets.find(x => x.id === tid); if (!t) return; - t.status = 'Paused'; c.history.push(`Paused target ${t.value}`); - const q = gradePauseTarget(t); - logAction('pause_target', t.value, q); - toast(`Paused target: ${t.value}.`, q.tone === 'bad' ? 'bad' : q.tone === 'warn' ? 'warn' : 'good'); render(); - } - function harvestTerm(cid, term) { - const source = campaignById(state, cid); - const sourceTerm = findSearchTermInCampaign(source, term); - const manual = state.campaigns.find(c => c.id === 'C-SP-MAN-002') || state.campaigns.find(c => c.type === 'SP' && c.targetingMode.includes('Manual')); - if (!manual) { toast('No manual SP campaign found for harvesting.', 'bad'); return; } - const ag = getPrimaryAdGroup(manual); - const duplicate = manual.targets.some(t => String(t.value).toLowerCase() === String(term).toLowerCase() && t.match === 'Exact'); - if (!duplicate) { - const target = { ...makeTarget('Keyword', term, 'Exact', Math.max(manual.defaultBid, 0.9)), campaignId:manual.id, adGroupId:ag.id }; - manual.targets.push(target); - manual.history.push(`Harvested search term as exact: ${term}`); - } - normalizeCampaign(manual); - const q = duplicate ? { tone:'warn', message:'Exact target already exists. Do not create duplicate harvested keywords.' } : gradeHarvest(sourceTerm); - logAction('harvest_term', term, q); - toast(duplicate ? `Exact keyword already exists: ${term}.` : `Harvested as exact keyword: ${term}.`, q.tone === 'bad' ? 'bad' : q.tone === 'warn' ? 'warn' : 'good'); render(); - } - function addNegative(cid, term, type) { - const c = campaignById(state, cid); if (!c) return; - const ag = getPrimaryAdGroup(c); - const sourceTerm = findSearchTermInCampaign(c, term); - const duplicate = c.negatives.some(n => n.type === type && String(n.value).toLowerCase() === String(term).toLowerCase()); - if (!duplicate) c.negatives.push({ id:uid('NEG'), campaignId:c.id, adGroupId:ag.id, type, value:term, sourceSearchTermId:sourceTerm?.id || '' }); - c.history.push(duplicate ? `Skipped duplicate ${type}: ${term}` : `Added ${type}: ${term}`); - normalizeCampaign(c); - const q = gradeNegative(c, sourceTerm, type, duplicate); - logAction('negative_added', term, q); - toast(duplicate ? `Duplicate ${type} skipped: ${term}.` : `Added ${type}: ${term}.`, q.tone === 'bad' ? 'bad' : q.tone === 'warn' ? 'warn' : 'good'); render(); - } - function manualNegative() { - const cid = $('#negativeCampaign')?.value; const type = $('#negativeType')?.value; const val = $('#negativeValue')?.value.trim(); - if (!cid || !val) { toast('Choose a campaign and enter a negative value.', 'bad'); return; } - addNegative(cid, val, type); - } - function createBudgetRule() { - const cid = $('#ruleCampaign')?.value; const type = $('#ruleType')?.value; const inc = Number($('#ruleIncrease')?.value || 0); const condition = $('#ruleCondition')?.value || ''; - const c = campaignById(state, cid); if (!c || !inc) { toast('Budget rule needs a campaign and increase value.', 'bad'); return; } - if (inc <= 0 || inc > 200) { logAction('budget_rule_created', `${cid} invalid ${inc}%`, { tone:'bad', message:'Budget rule increase must stay within 1% to 200% in this simulator.' }); toast('Budget rule increase must be 1% to 200%.', 'bad'); return; } - const rule = { id:uid('BR'), campaignId:c.id, name: `${type} rule ${c.budgetRules.length + 1}`, type, increase: inc, condition: condition || 'Training condition', status:'Enabled' }; - c.budgetRules.push(rule); c.history.push(`Budget rule created: ${rule.name}`); - logAction('budget_rule_created', `${c.name} +${inc}%`, inc > 100 ? { tone:'warn', message:'Large budget-rule increase. Confirm event plan and rollback threshold.' } : 'good'); - toast('Budget rule created.', inc > 100 ? 'warn' : 'good'); render(); - } - function repairCreative(id) { - const c = campaignById(state, id); if (!c || !c.creative) return; - if (c.creative.headline && /#1|best|guaranteed|perfect/i.test(c.creative.headline)) c.creative.headline = 'Upgrade your home coffee setup'; - c.creativeStatus = 'Approved'; - c.creativeIssue = ''; - c.history.push('Creative approval issue fixed in simulator'); - logAction('creative_repair', c.name, 'good'); - toast('Creative issue fixed and marked approved.', 'good'); - render(); - } - - function savePlacements(id) { - const c = campaignById(state, id); if (!c) return; - ['top','product','rest'].forEach(k => { - const input = $(`input[data-place="${k}"][data-id="${id}"]`); - if (input) c.placements[k] = Math.max(0, Math.min(900, Number(input.value || 0))); - }); - c.history.push(`Placement adjustments saved: top ${c.placements.top}%, product ${c.placements.product}%, rest ${c.placements.rest}%`); - const q = gradePlacementChange(c); - logAction('placement_change', c.name, q); toast('Placement adjustments saved.', q.tone === 'bad' ? 'bad' : q.tone === 'warn' ? 'warn' : 'good'); render(); - } - function saveCampaignSettings(id) { - const c = campaignById(state, id); if (!c) return; - const oldBudget = c.dailyBudget; - const oldStatus = c.status; - const nextStatus = $('#detailStatus')?.value || c.status; - if (nextStatus === 'Enabled' && c.type !== 'SP' && c.creativeStatus === 'Rejected') { - logAction('settings_change', `${c.name} enable blocked`, { tone:'bad', message:'Cannot enable a campaign with rejected creative. Repair creative first.' }); - toast('Enable blocked. Repair rejected creative first.', 'bad'); - render(); - return; - } - c.dailyBudget = Number($('#detailBudget')?.value || c.dailyBudget); - c.defaultBid = Number($('#detailBid')?.value || c.defaultBid); - c.bidStrategy = $('#detailBidStrategy')?.value || c.bidStrategy; - c.status = nextStatus; - if (oldStatus !== c.status) { c.adGroups.forEach(ag => ag.status = c.status); c.ads.forEach(ad => ad.status = c.status); c.targets.forEach(t => t.status = c.status); } - normalizeCampaign(c); - c.history.push(`Settings saved: budget ${fmt.money(c.dailyBudget)}, bid ${fmt.bid(c.defaultBid)}, status ${c.status}`); - if (oldBudget !== c.dailyBudget) logAction('budget_change', `${c.name} ${fmt.money(oldBudget)} to ${fmt.money(c.dailyBudget)}`, gradeBudgetChange(c, oldBudget, c.dailyBudget)); - else logAction('settings_change', c.name, oldStatus !== c.status ? 'good' : 'warn'); - toast('Campaign settings saved.', 'good'); render(); - } - function simulateDays() { - state.simulationDays += 7; - state.campaigns.forEach(c => { - if (c.status !== 'Enabled') return; - const quality = c.negatives.length * 0.03 + c.budgetRules.length * 0.02 + (c.placements.top > 30 ? 0.04 : 0) + (c.type === 'SD' && c.targetingMode.includes('Remarketing') ? 0.05 : 0); - const spend = Math.min(c.dailyBudget * 7 * (0.72 + Math.random() * 0.25), c.dailyBudget * 7); - const roasBase = c.type === 'SP' ? 3.2 : c.type === 'SB' ? 2.7 : 3.5; - const sales = spend * (roasBase + quality + (Math.random() - 0.4)); - const clicks = Math.round(spend / Math.max(0.35, c.defaultBid * (0.85 + Math.random() * .35))); - const impressions = Math.round(clicks / (0.006 + Math.random() * 0.012)); - const orders = Math.max(0, Math.round(sales / avgPrice(c.products))); - c.metrics.spend += spend; c.metrics.sales += Math.max(0, sales); c.metrics.clicks += clicks; c.metrics.impressions += impressions; c.metrics.orders += orders; - c.history.push('7-day simulation run added performance data'); - c.targets.forEach(t => { - if (t.status !== 'Enabled') return; - const share = 1 / Math.max(1, c.targets.length); - t.spend += spend * share; t.sales += Math.max(0, sales * share * (0.8 + Math.random() * .4)); t.clicks += Math.round(clicks * share); t.impressions += Math.round(impressions * share); t.orders += Math.round(orders * share); - }); - }); - logAction('simulation_run', '7 days'); toast('7 simulated days added. Metrics updated.', 'good'); render(); - } - function avgPrice(asins) { - const ps = asins.map(a => products.find(p => p.asin === a)).filter(Boolean); - return ps.length ? ps.reduce((a,p)=>a+p.price,0)/ps.length : 25; - } - function startMission(id) { - const sc = allScenarios().find(x => x.id === id); if (!sc) return; - state.activeScenarioId = id; - state.view = sc.startView; - if (sc.startView === 'campaigns' && sc.subjectId) { state.selectedCampaignId = sc.subjectId; state.selectedTab = 'overview'; } - else { state.selectedCampaignId = ''; state.selectedTab = sc.startView === 'campaigns' ? 'campaigns' : state.selectedTab; } - logAction('mission_started', sc.title, 'good'); - toast(`Mission started: ${sc.title}.`, 'good'); render(); - } - - function generateScenario(difficulty) { - const kind = difficulty === 'Beginner' ? 'sp-waste' : difficulty === 'Advanced' ? 'sd-scale' : SCENARIO_KINDS[Math.floor(Math.random() * SCENARIO_KINDS.length)]; - const pack = makeGeneratedScenario(kind, difficulty); - state.campaigns.unshift(pack.campaign); - state.generatedScenarios.unshift(pack.scenario); - state.generatedScenarios = state.generatedScenarios.slice(0, 8); - state.activeScenarioId = pack.scenario.id; - state.view = 'campaigns'; - state.selectedCampaignId = pack.campaign.id; - state.selectedTab = kind === 'sp-waste' ? 'searchTerms' : 'overview'; - normalizeState(); - logAction('scenario_generated', `${difficulty} ${pack.scenario.title}`, 'good'); - toast(`Generated scenario: ${pack.scenario.title}.`, 'good'); - render(); - } - - function makeGeneratedScenario(kind, difficulty) { - const suffix = Math.random().toString(36).slice(2, 6).toUpperCase(); - if (kind === 'sp-waste') { - const id = `C-SCN-SP-${suffix}`; - const ag = `AG-SCN-SP-${suffix}`; - const winnerTerm = 'metal aeropress filter'; - const wasteTerm = difficulty === 'Advanced' ? 'free coffee filter samples' : 'paper cone filters bulk'; - const campaign = { - id, type:'SP', name:`SP | Auto | Scenario Waste Cleanup | ${suffix}`, portfolio:'Scenario Lab', status:'Enabled', dailyBudget:difficulty === 'Beginner' ? 30 : 45, startDate:'2026-06-25', endDate:'', targetingMode:'Automatic', adFormat:'Standard', bidStrategy:'Dynamic bids - down only', defaultBid:0.74, products:['B0TRAIN001'], placements:{ top:10, product:0, rest:0 }, budgetRules:[], negatives:[], metrics:{ impressions:28400, clicks:344, spend:268.20, sales:318.90, orders:16 }, adGroups:[{ id:ag, name:'Auto scenario catch-all', status:'Enabled', defaultBid:0.74, campaignId:id }], ads:[], targets:[ - { id:`T-SCN-${suffix}-1`, campaignId:id, adGroupId:ag, type:'Auto', value:'Close match', match:'Auto', bid:0.74, status:'Enabled', impressions:9800, clicks:122, spend:89.20, sales:318.90, orders:16 }, - { id:`T-SCN-${suffix}-2`, campaignId:id, adGroupId:ag, type:'Auto', value:'Loose match', match:'Auto', bid:0.64, status:'Enabled', impressions:14800, clicks:176, spend:141.80, sales:0, orders:0 }, - { id:`T-SCN-${suffix}-3`, campaignId:id, adGroupId:ag, type:'Auto', value:'Substitutes', match:'Auto', bid:0.58, status:'Enabled', impressions:2400, clicks:25, spend:18.20, sales:0, orders:0 }, - { id:`T-SCN-${suffix}-4`, campaignId:id, adGroupId:ag, type:'Auto', value:'Complements', match:'Auto', bid:0.48, status:'Enabled', impressions:1400, clicks:21, spend:19.00, sales:0, orders:0 } - ], searchTerms:[ - { id:`ST-SCN-${suffix}-1`, campaignId:id, adGroupId:ag, targetId:`T-SCN-${suffix}-1`, term:winnerTerm, target:'Close match', clicks:42, spend:32.55, sales:119.95, orders:5, recommendation:'Harvest as exact keyword' }, - { id:`ST-SCN-${suffix}-2`, campaignId:id, adGroupId:ag, targetId:`T-SCN-${suffix}-2`, term:wasteTerm, target:'Loose match', clicks:39, spend:41.80, sales:0, orders:0, recommendation:'Add negative exact or phrase' } - ], history:['Generated SP waste scenario'], createdBySimulator:true - }; - return { campaign, scenario:{ id:`mission-scn-sp-${suffix}`, generated:true, kind, subjectId:id, type:'SP', title:`Generated SP Waste Cleanup ${suffix}`, difficulty, minutes:difficulty === 'Beginner' ? 12 : 18, startView:'campaigns', winnerTerm, wasteTerm, summary:'Find the zero-sale search term, block it safely, and harvest the winner into exact control.', coach:'Do not negative a converting term. Exact negative for the waste row is safe. Phrase negative needs more care.' } }; - } - if (kind === 'sb-creative') { - const id = `C-SCN-SB-${suffix}`; - const ag = `AG-SCN-SB-${suffix}`; - const campaign = { - id, type:'SB', name:`SB | Product Collection | Creative Fix | ${suffix}`, portfolio:'Scenario Lab', status:'Paused', dailyBudget:55, startDate:'2026-06-25', endDate:'', targetingMode:'Keyword', adFormat:'Product collection', bidStrategy:'Cost per click', defaultBid:1.05, products:['B0TRAIN001','B0TRAIN002','B0TRAIN004'], creative:{ headline:'The #1 best coffee upgrade', brandName:'Training Labs', logo:'TL', destination:'Brand Store', video:'', image:'Lifestyle' }, creativeStatus:'Rejected', creativeIssue:'Headline uses an unverifiable #1 claim.', placements:{ top:15, product:0, rest:0 }, budgetRules:[], negatives:[], metrics:{ impressions:0, clicks:0, spend:0, sales:0, orders:0 }, adGroups:[{ id:ag, name:'SB creative fix keywords', status:'Paused', defaultBid:1.05, campaignId:id }], ads:[], targets:[ - { id:`T-SCN-${suffix}-1`, campaignId:id, adGroupId:ag, type:'Keyword', value:'coffee accessories', match:'Phrase', bid:1.05, status:'Paused', impressions:0, clicks:0, spend:0, sales:0, orders:0 }, - { id:`T-SCN-${suffix}-2`, campaignId:id, adGroupId:ag, type:'Keyword', value:'coffee gift set', match:'Broad', bid:0.95, status:'Paused', impressions:0, clicks:0, spend:0, sales:0, orders:0 } - ], searchTerms:[], history:['Generated SB creative rejection scenario'], createdBySimulator:true - }; - return { campaign, scenario:{ id:`mission-scn-sb-${suffix}`, generated:true, kind, subjectId:id, type:'SB', title:`Generated SB Creative Repair ${suffix}`, difficulty, minutes:16, startView:'campaigns', summary:'Review a rejected Sponsored Brands creative, fix the claim, and enable only after approval.', coach:'Creative work is not bid work. Fix the rejected claim, confirm destination and products, then enable.' } }; - } - const id = `C-SCN-SD-${suffix}`; - const ag = `AG-SCN-SD-${suffix}`; - const campaign = { - id, type:'SD', name:`SD | Views Remarketing | Scale Decision | ${suffix}`, portfolio:'Scenario Lab', status:'Enabled', dailyBudget:35, startDate:'2026-06-25', endDate:'', targetingMode:'Audiences - views remarketing', adFormat:'Auto generated', bidStrategy:'Cost per click', defaultBid:0.70, products:['B0TRAIN001','B0TRAIN005'], creative:{ headline:'Still comparing coffee upgrades?', brandName:'Training Labs', logo:'TL', destination:'Product detail page', video:'', image:'Auto generated' }, placements:{ top:0, product:15, rest:0 }, budgetRules:[], negatives:[], metrics:{ impressions:64400, clicks:420, spend:294.00, sales:1764.00, orders:82 }, adGroups:[{ id:ag, name:'Scenario views remarketing', status:'Enabled', defaultBid:0.70, campaignId:id }], ads:[], targets:[ - { id:`T-SCN-${suffix}-1`, campaignId:id, adGroupId:ag, type:'Audience', value:'Viewed advertised products, 30 days', match:'Remarketing', bid:0.72, status:'Enabled', impressions:41200, clicks:300, spend:216, sales:1440, orders:65 }, - { id:`T-SCN-${suffix}-2`, campaignId:id, adGroupId:ag, type:'Audience', value:'Viewed similar products, 14 days', match:'Remarketing', bid:0.58, status:'Enabled', impressions:23200, clicks:120, spend:78, sales:324, orders:17 } - ], searchTerms:[], history:['Generated SD scale scenario'], createdBySimulator:true - }; - return { campaign, scenario:{ id:`mission-scn-sd-${suffix}`, generated:true, kind:'sd-scale', subjectId:id, type:'SD', title:`Generated SD Scale Decision ${suffix}`, difficulty, minutes:18, startView:'campaigns', summary:'Evaluate a strong SD remarketing campaign, create a budget rule, and simulate the follow-up.', coach:'SD scaling does not use search terms. Use audience performance, retail readiness, budget rules, and controlled follow-up.' } }; - } - - function goMissionStart() { - const sc = getActiveScenario(); if (!sc) return; - state.view = sc.startView; - if (sc.startView === 'campaigns' && sc.subjectId) { state.selectedCampaignId = sc.subjectId; state.selectedTab = 'overview'; } - else if (sc.startView === 'campaigns') { state.selectedCampaignId = ''; state.selectedTab = 'campaigns'; } - render(); - } - - function saveTrainee() { - state.traineeName = $('#traineeName')?.value.trim() || 'Trainee 1'; - logAction('trainee_saved', state.traineeName, 'good'); - toast('Trainee profile saved.', 'good'); render(); - } - function saveTrainerNotes() { - state.trainerNotes = $('#trainerNotes')?.value || ''; - logAction('trainer_notes_saved', 'Trainer notes updated', 'good'); - toast('Trainer notes saved.', 'good'); render(); - } - function downloadText(filename, text, type='text/plain') { - const blob = new Blob([text], { type }); - const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); - } - function exportTrainerLog() { - const rows = [['Trainee','Time','Action','Detail','Grade','Feedback']].concat(state.actionLog.map(a => [a.trainee || state.traineeName, a.time, a.type, a.detail, a.quality || '', a.feedback || ''])); - const csv = rows.map(r => r.map(v => '"' + String(v || '').replace(/"/g,'""') + '"').join(',')).join('\n'); - downloadText('ppc-simulator-trainer-log.csv', csv, 'text/csv'); - logAction('export_trainer_log', 'Trainer log exported', 'good'); toast('Trainer log exported.', 'good'); - } - function exportState() { - downloadText('ppc-simulator-progress.json', JSON.stringify(serializableState(), null, 2), 'application/json'); - logAction('export_progress', 'Progress JSON exported', 'good'); toast('Progress JSON exported.', 'good'); - } - function importState(e) { - const file = e.target.files && e.target.files[0]; if (!file) return; - const reader = new FileReader(); - reader.onload = () => { try { const saved = JSON.parse(reader.result); Object.keys(saved).forEach(k => { if (k in state && k !== 'toasts') state[k] = saved[k]; }); state.toasts=[]; normalizeState(); logAction('import_progress', 'Progress JSON imported', 'good'); toast('Progress imported and normalized.', 'good'); render(); } catch(err) { toast('Import failed. Use a valid simulator JSON export.', 'bad'); } }; - reader.readAsText(file); - } - function clearProgress() { - localStorage.removeItem(STORAGE_KEY); - resetAll(); - toast('Saved browser progress cleared.', 'warn'); - } - function parseBulk() { - const text = $('#bulkInput')?.value ?? state.bulkInput; - state.bulkInput = text; - const rows = csvParseRows(text); - if (!rows.length) return []; - const header = rows.shift().map(h => h.trim()); - const byName = name => header.findIndex(h => h.toLowerCase() === name.toLowerCase()); - const modern = byName('entity') >= 0; - return rows.map((cols, i) => { - if (modern) { - const get = name => { const idx = byName(name); return idx >= 0 ? (cols[idx] || '').trim() : ''; }; - return { row:i+2, entity:get('entity'), operation:get('operation'), campaignId:get('campaignId'), adGroupId:get('adGroupId'), targetId:get('targetId'), target:get('target'), value:get('value'), bid:get('bid'), type:get('type'), status:get('status'), placement:get('placement'), percentage:get('percentage'), condition:get('condition') }; - } - return { row:i+2, legacy:true, action:cols[0] || '', campaignId:cols[1] || '', target:cols[2] || '', value:cols[3] || '', bid:cols[4] || '', type:cols[5] || '' }; - }); - } - - function findBulkTarget(c, r) { - const targetId = String(r.targetId || '').trim(); - const targetValue = String(r.target || '').trim().toLowerCase(); - return c.targets.find(t => targetId && t.id === targetId) || c.targets.find(t => targetValue && String(t.value).toLowerCase() === targetValue); - } - - function validateBulkRow(r) { - const c = campaignById(state, r.campaignId); - if (!c) return { ...r, valid:false, severity:'bad', message:'Campaign ID not found.' }; - if (r.legacy) return validateLegacyBulkRow(r, c); - const entity = String(r.entity || '').toLowerCase(); - const operation = String(r.operation || '').toLowerCase(); - if (entity === 'campaign' && operation === 'update') { - if (r.status && !['Enabled','Paused','Archived'].includes(r.status)) return { ...r, valid:false, severity:'bad', message:'Campaign status must be Enabled, Paused, or Archived.' }; - if (r.status === 'Enabled' && c.type !== 'SP' && c.creativeStatus === 'Rejected') return { ...r, valid:false, severity:'bad', message:'Cannot enable a rejected creative campaign.' }; - if (r.value && Number(r.value) <= 0) return { ...r, valid:false, severity:'bad', message:'Budget value must be greater than zero.' }; - if (r.value && Number(r.value) < minimumBudget(c.type)) return { ...r, valid:true, severity:'warn', message:`Budget is below ${c.type} training floor: ${fmt.money(minimumBudget(c.type))}.` }; - if (!r.value && !r.status) return { ...r, valid:false, severity:'bad', message:'Campaign update needs budget value or status.' }; - const q = r.value ? gradeBudgetChange(c, c.dailyBudget, Number(r.value)) : { tone:r.status === 'Archived' ? 'warn' : 'good', message:`Set campaign status to ${r.status}.` }; - return { ...r, valid:true, severity:q.tone === 'bad' ? 'warn' : q.tone, message:r.status ? `Set campaign status to ${r.status}. ${q.message}` : `Set budget to ${fmt.money(r.value)}. ${q.message}` }; - } - if (entity === 'target') { - const t = findBulkTarget(c, r); - if (!t) return { ...r, valid:false, severity:'bad', message:'Target not found by targetId or target value in campaign.' }; - if (operation === 'pause') { const q = gradePauseTarget(t); return { ...r, valid:true, severity:q.tone === 'bad' ? 'warn' : q.tone, message:`Pause target ${t.value}. ${q.message}` }; } - if (operation === 'update') { - if (!(Number(r.bid) > 0)) return { ...r, valid:false, severity:'bad', message:'Target update needs a bid greater than zero.' }; - const mult = Number(r.bid) > Number(t.bid) ? 1.1 : 0.9; - const q = gradeBidMove(c, t, mult, t.bid, Number(r.bid)); - return { ...r, valid:true, severity:q.tone === 'bad' ? 'warn' : q.tone, message:`Set ${t.value} bid to ${fmt.bid(r.bid)}. ${q.message}` }; - } - } - if (entity === 'negative' && operation === 'create') { - if (!r.value) return { ...r, valid:false, severity:'bad', message:'Negative create needs a value.' }; - const type = r.type || 'Negative exact'; - const st = findSearchTermInCampaign(c, r.value); - const duplicate = c.negatives.some(n => n.type === type && String(n.value).toLowerCase() === String(r.value).toLowerCase()); - const q = gradeNegative(c, st, type, duplicate); - return { ...r, valid: q.tone !== 'bad', severity:q.tone, message:`Add ${type}: ${r.value}. ${q.message}` }; - } - if (entity === 'placement' && operation === 'update') { - if (!['top','product','rest'].includes(String(r.placement).toLowerCase())) return { ...r, valid:false, severity:'bad', message:'Placement must be top, product, or rest.' }; - const pct = Number(r.percentage); - if (pct < 0 || pct > 900 || Number.isNaN(pct)) return { ...r, valid:false, severity:'bad', message:'Placement percentage must be 0 to 900.' }; - return { ...r, valid:true, severity:pct > 100 ? 'warn' : 'good', message:`Set ${r.placement} placement to ${pct}%.` }; - } - if (entity === 'budgetrule' && operation === 'create') { - const pct = Number(r.percentage); - if (!r.value) return { ...r, valid:false, severity:'bad', message:'BudgetRule needs a rule name in value column.' }; - if (pct <= 0 || pct > 200 || Number.isNaN(pct)) return { ...r, valid:false, severity:'bad', message:'Budget rule percentage must be 1 to 200.' }; - return { ...r, valid:true, severity:pct > 100 ? 'warn' : 'good', message:`Create budget rule ${r.value} at +${pct}%.` }; - } - return { ...r, valid:false, severity:'bad', message:'Unsupported entity or operation.' }; - } - - function validateLegacyBulkRow(r, c) { - if (r.action === 'campaign_budget') { - if (!(Number(r.value) > 0)) return { ...r, valid:false, severity:'bad', message:'Budget value must be greater than zero.' }; - const q = gradeBudgetChange(c, c.dailyBudget, Number(r.value)); - return { ...r, entity:'Campaign', operation:'Update', valid:true, severity:q.tone === 'bad' ? 'warn' : q.tone, message:`Set budget to ${fmt.money(r.value)}. ${q.message}` }; - } - if (r.action === 'target_bid') { - const t = c.targets.find(x => x.value.toLowerCase() === String(r.target).toLowerCase()); - if (!t) return { ...r, valid:false, severity:'bad', message:'Target not found in campaign.' }; - if (!(Number(r.bid) > 0)) return { ...r, valid:false, severity:'bad', message:'Bid must be greater than zero.' }; - const q = gradeBidMove(c, t, Number(r.bid) > Number(t.bid) ? 1.1 : 0.9, t.bid, Number(r.bid)); - return { ...r, entity:'Target', operation:'Update', valid:true, severity:q.tone === 'bad' ? 'warn' : q.tone, message:`Set ${r.target} bid to ${fmt.bid(r.bid)}. ${q.message}` }; - } - if (r.action === 'target_pause') { - const t = c.targets.find(x => x.value.toLowerCase() === String(r.target).toLowerCase()); - if (!t) return { ...r, valid:false, severity:'bad', message:'Target not found in campaign.' }; - const q = gradePauseTarget(t); - return { ...r, entity:'Target', operation:'Pause', valid:true, severity:q.tone === 'bad' ? 'warn' : q.tone, message:`Pause target ${r.target}. ${q.message}` }; - } - if (r.action === 'negative_exact' || r.action === 'negative_phrase') { - if (!r.value) return { ...r, valid:false, severity:'bad', message:'Negative value is required.' }; - const type = r.action === 'negative_exact' ? 'Negative exact' : 'Negative phrase'; - const st = findSearchTermInCampaign(c, r.value); - const duplicate = c.negatives.some(n => n.type === type && String(n.value).toLowerCase() === String(r.value).toLowerCase()); - const q = gradeNegative(c, st, type, duplicate); - return { ...r, entity:'Negative', operation:'Create', valid:q.tone !== 'bad', severity:q.tone, message:`Add ${type}: ${r.value}. ${q.message}` }; - } - return { ...r, valid:false, severity:'bad', message:'Unsupported legacy action.' }; - } - - function previewBulk() { - state.bulkPreview = parseBulk().map(validateBulkRow); - const hasErrors = state.bulkPreview.some(r => !r.valid); - const hasWarnings = state.bulkPreview.some(r => r.valid && r.severity === 'warn'); - logAction('bulk_preview', `${state.bulkPreview.length} rows validated`, hasErrors ? 'bad' : hasWarnings ? 'warn' : 'good'); - toast('Bulk sheet validated.', hasErrors ? 'bad' : hasWarnings ? 'warn' : 'good'); render(); - } - - function applyBulk() { - if (!state.bulkPreview.length) state.bulkPreview = parseBulk().map(validateBulkRow); - const valid = state.bulkPreview.filter(r => r.valid); - valid.forEach(r => { - const c = campaignById(state, r.campaignId); if (!c) return; - if (r.legacy) return applyLegacyBulkRow(r, c); - const entity = String(r.entity || '').toLowerCase(); - const operation = String(r.operation || '').toLowerCase(); - if (entity === 'campaign' && operation === 'update') { if (r.value) c.dailyBudget = Number(r.value); if (r.status) c.status = r.status; c.history.push(`Bulk updated campaign: budget ${fmt.money(c.dailyBudget)}, status ${c.status}`); } - if (entity === 'target') { const t = findBulkTarget(c, r); if (t && operation === 'update') { t.bid = Number(r.bid); c.history.push(`Bulk updated target bid ${t.value} to ${fmt.bid(t.bid)}`); } if (t && operation === 'pause') { t.status = 'Paused'; c.history.push(`Bulk paused target ${t.value}`); } } - if (entity === 'negative' && operation === 'create') { const ag = getPrimaryAdGroup(c); if (!c.negatives.some(n => n.type === (r.type || 'Negative exact') && String(n.value).toLowerCase() === String(r.value).toLowerCase())) c.negatives.push({ id:uid('NEG'), campaignId:c.id, adGroupId:ag.id, type:r.type || 'Negative exact', value:r.value }); c.history.push(`Bulk added negative ${r.type || 'Negative exact'}: ${r.value}`); } - if (entity === 'placement' && operation === 'update') { const key = String(r.placement).toLowerCase(); c.placements[key] = Number(r.percentage); c.history.push(`Bulk updated ${key} placement to ${r.percentage}%`); } - if (entity === 'budgetrule' && operation === 'create') { c.budgetRules.push({ id:uid('BR'), campaignId:c.id, name:r.value, type:r.type || 'Schedule', increase:Number(r.percentage), condition:r.condition || 'Bulk training rule' }); c.history.push(`Bulk created budget rule ${r.value}`); } - }); - normalizeState(); - logAction('bulk_apply', `${valid.length} valid rows applied`, valid.length === state.bulkPreview.length ? 'good' : 'warn'); - toast(`${valid.length} bulk rows applied to sandbox.`, valid.length ? 'good' : 'bad'); render(); - } - - function applyLegacyBulkRow(r, c) { - if (r.action === 'campaign_budget') { c.dailyBudget = Number(r.value); c.history.push(`Bulk updated budget to ${fmt.money(c.dailyBudget)}`); } - if (r.action === 'target_bid') { const t = c.targets.find(x => x.value.toLowerCase() === String(r.target).toLowerCase()); if (t) { t.bid = Number(r.bid); c.history.push(`Bulk updated target bid ${t.value} to ${fmt.bid(t.bid)}`); } } - if (r.action === 'target_pause') { const t = c.targets.find(x => x.value.toLowerCase() === String(r.target).toLowerCase()); if (t) { t.status = 'Paused'; c.history.push(`Bulk paused target ${t.value}`); } } - if (r.action === 'negative_exact' || r.action === 'negative_phrase') { - const type = r.action === 'negative_exact' ? 'Negative exact' : 'Negative phrase'; - const ag = getPrimaryAdGroup(c); - if (!c.negatives.some(n => n.type === type && String(n.value).toLowerCase() === String(r.value).toLowerCase())) c.negatives.push({ id:uid('NEG'), campaignId:c.id, adGroupId:ag.id, type, value:r.value }); - } - } - - function downloadBulkTemplate() { - downloadText('ppc-simulator-v3-bulk-template.csv', 'entity,operation,campaignId,adGroupId,targetId,target,value,bid,type,status,placement,percentage,condition\nCampaign,Update,C-SP-AUTO-001,,,,42,,,,,,\nTarget,Update,C-SP-MAN-002,,T-005,stainless coffee filter,,1.32,,,,,\nTarget,Create,C-SP-MAN-002,AG-002,,,low intent coffee mug,0.31,Keyword,Enabled,,,\nNegative,Create,C-SP-AUTO-001,,,,paper coffee filters bulk,,Negative exact,,,,\nPlacement,Update,C-SP-MAN-002,,,,,,,,top,35,\nBudgetRule,Create,C-SP-MAN-002,,,,Prime Day pulse,,Schedule,,,25,Prime Day event week', 'text/csv'); - logAction('download_bulk_template', 'V3 bulk template downloaded', 'good'); toast('Bulk template downloaded.', 'good'); - } - - - function exportDocs() { - const md = `# Amazon PPC Training Simulator V3 Documentation - -## Purpose -Train virtual assistants on Amazon PPC navigation and operations without Seller Central or Amazon Ads access. - -## V3 focus -V3 tightens the simulator around object relationships, repeatable scenarios, bulk validation, report workflows, and trainer QA. - -## Core object model -- Campaign is the parent object. -- Ad groups belong to one campaign through campaignId. -- Product ads belong to one campaign and one ad group. -- Targets belong to one campaign and one ad group. -- Search terms belong to one campaign and link back to targetId when the source target is known. -- Negatives belong to one campaign and one ad group. -- Budget rules belong to one campaign. -- Reports read from the relationship graph instead of loose mock rows. - -## V3 modules -- Sponsored Products setup, auto targeting, manual keyword targeting, product targeting, harvesting, negatives, placements, and budgets. -- Sponsored Brands Product Collection, Store Spotlight, Video, creative approval, destination checks, and targeting. -- Sponsored Display contextual targeting, views remarketing, purchases remarketing, audience logic, and no-search-term reporting behavior. -- Report center queue with Search term, Targeting, Campaign placement, Budget, Advertised product, Purchased product, and Campaign reports. -- Bulk operations for Campaign, Target, Negative, Placement, and BudgetRule entities. -- Integrity center for relationship checks and self-healing. -- Scenario generator for repeated beginner, intermediate, and advanced VA drills. -- Trainer dashboard, action grading, notes, progress import/export, and CSV logs. - -## Recommended training path -1. Navigation map and glossary. -2. SP Auto and Manual campaign setup. -3. Search term harvesting and negative exact/phrase decisions. -4. SB creative and Store destination review. -5. SD contextual and audience setup. -6. Reports and bulk operations. -7. Randomized scenario drills. -8. Trainer review and certification. - -## QA rules -- Run Integrity center after bulk operations or imported progress. -- Export trainer log after each trainee session. -- Use generated scenarios for repeat practice. -- Require VAs to explain the rollback step before risky actions. - -## Known limits -This app is static and local to the browser. It does not connect to Amazon Ads, Seller Central, live reports, or real bulk uploads. Metrics are simulated and are not predictive.`; - downloadText('amazon-ppc-simulator-v3-docs.md', md, 'text/markdown'); - logAction('export_docs', 'V3 documentation exported', 'good'); toast('Documentation exported.', 'good'); - } - - function resetAll() { - state.campaigns = copy(initialCampaigns); - state.selectedCampaignId=''; state.selectedTab='campaigns'; state.filterType='All'; state.filterStatus='All'; state.search=''; - state.draft=makeDraft(); state.wizardStep=1; state.actionLog=[]; state.feedbackLog=[]; state.bulkPreview=[]; state.activeScenarioId=''; state.generatedScenarios=[]; state.reportQueue=[]; state.selectedReportId=''; state.integrityLastRun=''; state.simulationDays=0; - normalizeState(); - toast('Simulator reset.', 'warn'); render(); - } - - function exportCsv() { - const rows = reportRowsFor(state.reportType); - const headers = rows.length ? Object.keys(rows[0]) : ['message']; - const matrix = [headers].concat(rows.length ? rows.map(r => headers.map(h => r[h])) : [['No rows']]); - const csv = matrix.map(r => r.map(v => '"' + String(v ?? '').replace(/"/g,'""') + '"').join(',')).join('\n'); - downloadText(`ppc-simulator-${state.reportType.toLowerCase().replace(/\s+/g,'-')}.csv`, csv, 'text/csv'); - logAction('export_report', state.reportType, 'good'); toast('CSV report exported.', 'good'); - } - - function copyReport() { - const rows = reportRowsFor(state.reportType); - const headers = rows.length ? Object.keys(rows[0]) : ['message']; - const text = [headers.join('\t')].concat(rows.map(r => headers.map(h => r[h]).join('\t'))).join('\n'); - navigator.clipboard?.writeText(text); logAction('copy_report', state.reportType, 'good'); toast('Report rows copied.', 'good'); - } - - - - - /* V3.2 hardening layer: relationship-safe overrides, bulk validator, docs, and certification */ - var V31_BULK_TEMPLATE = 'entity,operation,campaignId,adGroupId,targetId,target,value,bid,type,status,placement,percentage,condition\nCampaign,Update,C-SP-AUTO-001,,,,42,,,,,,\nTarget,Update,C-SP-MAN-002,,T-005,stainless coffee filter,,1.32,,,,,\nTarget,Create,C-SP-MAN-002,AG-002,,,low intent coffee mug,0.31,Keyword,Enabled,,,\nNegative,Create,C-SP-AUTO-001,,,paper coffee filters bulk,,Negative exact,,,,\nPlacement,Update,C-SP-MAN-002,,,,,,,top,35,\nBudgetRule,Create,C-SP-MAN-002,,,Prime Day pulse,,,Schedule,,,25,Prime Day event week'; - - function v31AppendHistory(c, detail, actor) { - if (!c) return; - if (!Array.isArray(c.history)) c.history = []; - c.history.push({ id: uid('H'), campaignId: c.id, time: new Date().toISOString(), detail, actor: actor || state.traineeName || 'Training VA' }); - } - - function v31RetailStatus(asin) { - const p = products.find(x => x.asin === asin); - return p ? p.status : 'Unknown'; - } - - function v31HasLowInventory(c) { - return (c.products || []).some(asin => v31RetailStatus(asin) === 'Low Inventory'); - } - - function v31FindSearchTerm(term, campaignId) { - const wanted = String(term || '').toLowerCase(); - for (const c of state.campaigns) { - if (campaignId && c.id !== campaignId) continue; - const st = (c.searchTerms || []).find(x => String(x.term || '').toLowerCase() === wanted); - if (st) return { campaign:c, searchTerm:st }; - } - return null; - } - - function v31ReportRows() { - return reportRowsFor(state.reportType || 'Search term report'); - } - - function v31Csv(rows) { - return rows.map(r => r.map(v => '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"').join(',')).join('\n'); - } - - function validateDraftDetailed(d) { - const errors = []; - const warnings = []; - const name = String(d.name || '').trim(); - if (!name) errors.push('Campaign name is required.'); - if (name && !name.includes('|')) warnings.push('Use a consistent naming convention: Type | Strategy | Product | Purpose.'); - if (!Array.isArray(d.products) || !d.products.length) errors.push('Select at least one advertised product.'); - if (Number(d.dailyBudget) <= 0) errors.push('Daily budget must be greater than zero.'); - if ((d.type === 'SP' || d.type === 'SB') && Number(d.dailyBudget) < 25) errors.push('Training rule: SP and SB daily budget should be at least $25.'); - if (d.type === 'SD' && Number(d.dailyBudget) < 20) errors.push('Training rule: SD daily budget should be at least $20.'); - if (Number(d.defaultBid) <= 0) errors.push('Default bid must be greater than zero.'); - if (d.endDate && d.startDate && new Date(d.endDate) < new Date(d.startDate)) errors.push('End date must be after the start date.'); - if ((d.products || []).some(asin => v31RetailStatus(asin) === 'Low Inventory') && d.status === 'Enabled' && Number(d.dailyBudget) >= 35) warnings.push('Low-inventory ASIN selected with scale-level budget. Launch paused or reduce budget.'); - if (d.type === 'SP') { - if (!['Automatic','Manual keyword','Manual product'].includes(d.targetingMode)) errors.push('Sponsored Products must use automatic, manual keyword, or manual product targeting.'); - if (d.targetingMode === 'Manual keyword' && lines(d.keywords).length < 3) errors.push('Manual keyword setup needs at least three keyword targets for training.'); - if (d.targetingMode === 'Manual product' && lines(d.asinTargets).length + lines(d.categoryTargets).length < 1) errors.push('Manual product targeting needs ASIN or category targets.'); - } - if (d.type === 'SB') { - if (!['Product collection','Store spotlight','Video'].includes(d.adFormat)) errors.push('Sponsored Brands format must be Product collection, Store spotlight, or Video.'); - if (!String(d.creative.brandName || '').trim()) errors.push('Sponsored Brands needs a brand name.'); - if (!String(d.creative.headline || '').trim()) errors.push('Sponsored Brands needs a headline.'); - if (d.adFormat !== 'Video' && d.products.length < 3) errors.push('Sponsored Brands Product Collection or Store Spotlight should use at least three products in this training flow.'); - if (d.adFormat === 'Video' && !String(d.creative.video || '').trim()) errors.push('Sponsored Brands Video requires a video asset placeholder.'); - if (d.adFormat === 'Store spotlight' && d.creative.destination !== 'Brand Store') warnings.push('Store Spotlight should point to Brand Store.'); - } - if (d.type === 'SD') { - if (!String(d.creative.headline || '').trim()) errors.push('Sponsored Display creative needs a headline placeholder.'); - if (!String(d.targetingMode || '').match(/Contextual|Audiences/i)) errors.push('Sponsored Display must use contextual or audience targeting.'); - if (String(d.targetingMode || '').match(/Keyword/i)) errors.push('Sponsored Display should not use keyword targeting in this simulator.'); - } - return { errors, warnings }; - } - - function validateDraft(d) { return validateDraftDetailed(d).errors; } - - function renderStepReview(d) { - const result = validateDraftDetailed(d); - const targets = draftTargets(d); - return `

Review and launch

Review the same items a VA should check before touching a real account. Failed validation is intentional training.

- ${result.errors.length ? `
Fix before launch:
    ${result.errors.map(e => `
  • ${safe(e)}
  • `).join('')}
` : `
Validation passed. This campaign is ready to launch in the simulator.
`} - ${result.warnings.length ? `
Trainer warnings:
    ${result.warnings.map(e => `
  • ${safe(e)}
  • `).join('')}
` : ''} -
- ${reviewRow('Ad type', campaignTypeLabel(d.type))} - ${reviewRow('Campaign name', d.name || 'Missing')} - ${reviewRow('Format', d.adFormat)} - ${reviewRow('Targeting', d.targetingMode)} - ${reviewRow('Products', d.products.map(productTitle).join(', '))} - ${reviewRow('Daily budget', fmt.money(d.dailyBudget))} - ${reviewRow('Bid strategy', d.bidStrategy)} - ${reviewRow('Default bid', fmt.bid(d.defaultBid))} - ${reviewRow('Targets', targets.map(t => `${t.value} (${t.match})`).join(', ') || 'Auto targets generated')} - ${d.type !== 'SP' ? reviewRow('Creative', `${d.creative.brandName} | ${d.creative.headline} | ${d.creative.destination}`) : ''} - ${reviewRow('Placement adjustments', `Top ${d.placements.top}%, Product pages ${d.placements.product}%, Rest ${d.placements.rest}%`)} -
`; - } - - function launchCampaign() { - const d = copy(state.draft); - const result = validateDraftDetailed(d); - if (result.errors.length) { toast('Launch blocked. Fix validation errors first.', 'bad'); render(); return; } - const id = uid('C-' + d.type); - const ag = { id: uid('AG'), campaignId: id, name: d.type + ' training ad group', status: d.status, defaultBid: Number(d.defaultBid) }; - const targets = draftTargets(d).map(t => Object.assign(t, { campaignId: id, adGroupId: ag.id })); - const c = { - id, type: d.type, name: d.name, portfolio: d.portfolio, status: d.status, dailyBudget: Number(d.dailyBudget), startDate: d.startDate, endDate: d.endDate, - targetingMode: d.targetingMode, adFormat: d.adFormat, bidStrategy: d.bidStrategy, defaultBid: Number(d.defaultBid), products: uniqueList(d.products), - creative: d.type === 'SP' ? null : copy(d.creative), creativeStatus: result.warnings.length ? 'Pending review' : 'Approved', creativeIssue: result.warnings.join(' '), - placements: copy(d.placements), budgetRules: [], negatives: [], metrics: { impressions:0, clicks:0, spend:0, sales:0, orders:0 }, adGroups: [ag], targets, - searchTerms: d.type === 'SD' ? [] : seedSearchTerms(d), history: [], createdBySimulator: true - }; - v31AppendHistory(c, 'Campaign launched in simulator with normalized parent-child relationships.'); - state.campaigns.unshift(c); - normalizeState(); - state.selectedCampaignId = id; - state.selectedTab = 'overview'; - state.view = 'campaigns'; - state.draft = makeDraft(); - state.wizardStep = 1; - logAction('campaign_created', `${c.type} ${c.name}`, result.warnings.length ? 'warn' : 'good'); - toast(`${campaignTypeLabel(c.type)} campaign launched in training sandbox.`, result.warnings.length ? 'warn' : 'good'); - } - - function duplicateCampaign(id) { - const c = campaignById(state, id); if (!c) return; - normalizeCampaign(c); - const n = copy(c); - const agMap = {}; - n.id = uid('C-' + n.type); - n.name += ' copy'; - n.status = 'Paused'; - n.createdBySimulator = true; - n.metrics = { impressions:0, clicks:0, spend:0, sales:0, orders:0 }; - n.history = []; - n.adGroups.forEach(ag => { const old = ag.id; ag.id = uid('AG'); ag.campaignId = n.id; ag.status = 'Paused'; agMap[old] = ag.id; }); - n.ads = (n.ads || []).map(ad => Object.assign(ad, { id: uid('AD'), campaignId: n.id, adGroupId: agMap[ad.adGroupId] || n.adGroups[0].id, status: 'Paused' })); - n.targets = (n.targets || []).map(t => Object.assign(t, { id: uid('T'), campaignId: n.id, adGroupId: agMap[t.adGroupId] || n.adGroups[0].id, status: 'Paused', impressions:0, clicks:0, spend:0, sales:0, orders:0 })); - n.searchTerms = []; - n.negatives = (n.negatives || []).map(x => Object.assign(x, { id: uid('NEG'), campaignId: n.id, adGroupId: agMap[x.adGroupId] || n.adGroups[0].id })); - n.budgetRules = (n.budgetRules || []).map(r => Object.assign(r, { id: uid('BR'), campaignId: n.id })); - if (n.creative) n.creativeStatus = 'Draft'; - v31AppendHistory(n, 'Duplicated as paused copy with regenerated child IDs.'); - state.campaigns.unshift(n); - normalizeState(); - logAction('duplicate_campaign', c.name, 'good'); - toast('Campaign duplicated as paused copy with fresh object IDs.', 'good'); - render(); - } - - function toggleStatus(id) { - const c = campaignById(state, id); if (!c) return; - c.status = c.status === 'Enabled' ? 'Paused' : 'Enabled'; - (c.adGroups || []).forEach(ag => ag.status = c.status); - (c.ads || []).forEach(ad => ad.status = c.status); - v31AppendHistory(c, `Status changed to ${c.status}`); - logAction('status_change', `${c.name} ${c.status}`); - toast(`${c.name} is now ${c.status}.`, 'good'); - render(); - } - - function archiveCampaign(id) { - const c = campaignById(state, id); if (!c) return; - c.status = 'Archived'; - (c.adGroups || []).forEach(ag => ag.status = 'Archived'); - (c.ads || []).forEach(ad => ad.status = 'Archived'); - (c.targets || []).forEach(t => t.status = 'Archived'); - v31AppendHistory(c, 'Campaign archived with child objects archived.'); - logAction('archive_campaign', c.name); - toast('Campaign archived in simulator.', 'warn'); - render(); - } - - function harvestTerm(cid, term) { - const source = campaignById(state, cid); - const row = source && (source.searchTerms || []).find(x => String(x.term).toLowerCase() === String(term).toLowerCase()); - const manual = state.campaigns.find(c => c.id === 'C-SP-MAN-002') || state.campaigns.find(c => c.type === 'SP' && String(c.targetingMode).includes('Manual')); - if (!manual) { toast('No manual SP campaign found for harvesting.', 'bad'); return; } - normalizeCampaign(manual); - const agId = manual.adGroups[0].id; - if (!manual.targets.some(t => t.value.toLowerCase() === String(term).toLowerCase() && t.match === 'Exact')) { - manual.targets.push(Object.assign(makeTarget('Keyword', term, 'Exact', Math.max(manual.defaultBid, 0.9)), { campaignId: manual.id, adGroupId: agId })); - v31AppendHistory(manual, `Harvested search term as exact: ${term}`); - } - const q = row && row.orders > 0 && row.sales > 0 ? 'good' : 'bad'; - logAction('harvest_term', `${term} | orders ${row ? row.orders : 0} | sales ${row ? row.sales : 0}`, q); - toast(`Harvested as exact keyword: ${term}.`, q === 'good' ? 'good' : 'bad'); - render(); - } - - function addNegative(cid, term, type) { - const c = campaignById(state, cid); if (!c) return; - normalizeCampaign(c); - const st = (c.searchTerms || []).find(x => String(x.term).toLowerCase() === String(term).toLowerCase()); - if (!c.negatives.some(n => n.value.toLowerCase() === String(term).toLowerCase() && n.type === type)) { - c.negatives.push({ id: uid('NEG'), campaignId: c.id, adGroupId: c.adGroups[0].id, type, value: term, sourceSearchTermId: st ? st.id : '' }); - } - v31AppendHistory(c, `Added ${type}: ${term}`); - const q = st && st.orders > 0 ? 'bad' : st && st.clicks >= 20 && st.sales === 0 ? 'good' : 'warn'; - logAction('negative_added', `${term} | orders ${st ? st.orders : 'na'} | sales ${st ? st.sales : 'na'}`, q); - toast(`Added ${type}: ${term}.`, q === 'bad' ? 'bad' : 'good'); - render(); - } - - function adjustTargetBid(cid, tid, mult) { - const c = campaignById(state, cid); const t = c && c.targets.find(x => x.id === tid); if (!t) return; - const old = Number(t.bid || 0); - t.bid = Math.max(0.02, Math.round(t.bid * mult * 100) / 100); - const acos = calc(t).acos; - const q = mult > 1 && (t.sales === 0 || acos > 40) ? 'bad' : mult < 1 && (t.sales === 0 || acos > 40) ? 'good' : 'warn'; - v31AppendHistory(c, `Bid updated for ${t.value} from ${fmt.bid(old)} to ${fmt.bid(t.bid)}`); - logAction(mult > 1 ? 'bid_up' : 'bid_down', `${t.value} ${fmt.bid(t.bid)} ACOS ${fmt.pct(acos)}`, q); - toast(`Bid updated for ${t.value}: ${fmt.bid(t.bid)}.`, q === 'bad' ? 'bad' : 'good'); - render(); - } - - function pauseTarget(cid, tid) { - const c = campaignById(state, cid); const t = c && c.targets.find(x => x.id === tid); if (!t) return; - t.status = 'Paused'; - v31AppendHistory(c, `Paused target ${t.value}`); - const q = t.sales === 0 && t.clicks >= 20 ? 'good' : 'warn'; - logAction('pause_target', t.value, q); - toast(`Paused target: ${t.value}.`, q); - render(); - } - - function v31ParseCsv(text) { - const rows = []; - let row = [], cell = '', quoted = false; - text = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - for (let i = 0; i < text.length; i++) { - const ch = text[i], next = text[i + 1]; - if (ch === '"' && quoted && next === '"') { cell += '"'; i++; continue; } - if (ch === '"') { quoted = !quoted; continue; } - if (ch === ',' && !quoted) { row.push(cell.trim()); cell = ''; continue; } - if (ch === '\n' && !quoted) { row.push(cell.trim()); if (row.some(Boolean)) rows.push(row); row = []; cell = ''; continue; } - cell += ch; - } - row.push(cell.trim()); if (row.some(Boolean)) rows.push(row); - return rows; - } - - function v31NormHeader(h) { return String(h || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, ''); } - - function parseBulk() { - const text = $('#bulkInput')?.value ?? state.bulkInput; - state.bulkInput = text; - const rows = v31ParseCsv(text); - if (!rows.length) return []; - const headers = rows.shift().map(v31NormHeader); - return rows.map((cols, i) => { - const rec = { row: i + 2 }; - headers.forEach((h, idx) => rec[h] = String(cols[idx] || '').trim()); - rec.entity = rec.entity || ''; - rec.operation = rec.operation || ''; - rec.action = rec.action || v31LegacyAction(rec); - rec.campaignId = rec.campaignid || ''; - rec.adGroupId = rec.adgroupid || ''; - rec.targetId = rec.targetid || ''; - rec.target = rec.target || ''; - rec.value = rec.value || ''; - rec.bid = rec.bid || ''; - rec.type = rec.type || ''; - rec.status = rec.status || ''; - rec.placement = rec.placement || ''; - rec.percentage = rec.percentage || ''; - rec.condition = rec.condition || ''; - return rec; - }); - } - - function v31LegacyAction(r) { - if (r.action) return r.action; - const e = String(r.entity || '').toLowerCase(); - const o = String(r.operation || '').toLowerCase(); - if (e === 'campaign' && o === 'update') return 'campaign_update'; - if (e === 'target' && o === 'update') return 'target_update'; - if (e === 'target' && o === 'pause') return 'target_pause'; - if (e === 'target' && o === 'create') return 'target_create'; - if (e === 'negative' && o === 'create') return 'negative_create'; - if (e === 'placement' && o === 'update') return 'placement_update'; - if (e === 'budgetrule' && o === 'create') return 'budget_rule_create'; - if (e === 'productad' && o === 'pause') return 'product_ad_pause'; - return ''; - } - - function v31FindBulkTarget(c, r) { - return c && ((r.targetId && c.targets.find(t => t.id === r.targetId)) || c.targets.find(t => String(t.value).toLowerCase() === String(r.target).toLowerCase())); - } - - function validateBulkRow(r) { - const c = campaignById(state, r.campaignId); - if (!c) return Object.assign(r, { valid:false, message:'Campaign ID not found.' }); - normalizeCampaign(c); - if (r.action === 'campaign_budget' || r.action === 'campaign_update') { - if (r.value && Number(r.value) <= 0) return Object.assign(r, { valid:false, message:'Budget value must be greater than zero.' }); - if (r.status && !['Enabled','Paused','Archived'].includes(r.status)) return Object.assign(r, { valid:false, message:'Campaign status must be Enabled, Paused, or Archived.' }); - return Object.assign(r, { valid:true, message:'Update campaign budget or status.' }); - } - if (r.action === 'target_bid' || r.action === 'target_update' || r.action === 'target_pause') { - const t = v31FindBulkTarget(c, r); - if (!t) return Object.assign(r, { valid:false, message:'Target not found by targetId or target text.' }); - if ((r.action !== 'target_pause') && r.bid && Number(r.bid) <= 0) return Object.assign(r, { valid:false, message:'Bid must be greater than zero.' }); - if (r.status && !['Enabled','Paused','Archived'].includes(r.status)) return Object.assign(r, { valid:false, message:'Invalid target status.' }); - r.targetId = t.id; - return Object.assign(r, { valid:true, message:'Update target ' + t.value + '.' }); - } - if (r.action === 'target_create') { - const agId = r.adGroupId || c.adGroups[0].id; - if (!c.adGroups.some(ag => ag.id === agId)) return Object.assign(r, { valid:false, message:'Ad group ID does not belong to campaign.' }); - if (!r.value && !r.target) return Object.assign(r, { valid:false, message:'New target needs value or target.' }); - if (Number(r.bid) <= 0) return Object.assign(r, { valid:false, message:'New target needs bid greater than zero.' }); - r.adGroupId = agId; - return Object.assign(r, { valid:true, message:'Create target under ad group ' + agId + '.' }); - } - if (r.action === 'negative_exact' || r.action === 'negative_phrase' || r.action === 'negative_create') { - const value = r.value || r.target; - if (!value) return Object.assign(r, { valid:false, message:'Negative value is required.' }); - r.value = value; - return Object.assign(r, { valid:true, message:'Add negative ' + value + '.' }); - } - if (r.action === 'placement_update') { - const p = String(r.placement || r.target || '').toLowerCase(); - const pct = Number(r.percentage || r.value || 0); - if (!['top','product','rest'].includes(p)) return Object.assign(r, { valid:false, message:'Placement must be top, product, or rest.' }); - if (pct < 0 || pct > 900) return Object.assign(r, { valid:false, message:'Placement percentage must be 0 to 900.' }); - r.placement = p; r.percentage = pct; - return Object.assign(r, { valid:true, message:'Set ' + p + ' placement to ' + pct + '%.' }); - } - if (r.action === 'budget_rule_create') { - if (Number(r.percentage || r.value || 0) <= 0) return Object.assign(r, { valid:false, message:'Budget rule increase percentage is required.' }); - return Object.assign(r, { valid:true, message:'Create budget rule.' }); - } - if (r.action === 'product_ad_pause') { - const ad = c.ads.find(x => x.id === r.value || x.asin === r.value || x.asin === r.target); - if (!ad) return Object.assign(r, { valid:false, message:'Product ad not found by ID or ASIN.' }); - r.adId = ad.id; - return Object.assign(r, { valid:true, message:'Pause product ad ' + ad.asin + '.' }); - } - return Object.assign(r, { valid:false, message:'Unsupported action or entity-operation pair.' }); - } - - function renderBulkOps() { - return `${pageTitle('Bulk operations', 'Practice bulk-sheet thinking with parent-child validation. Paste CSV, validate rows, preview changes, then apply to sandbox data.', '')} -
-

Bulk sheet input

V3 relationship mode
-

Supported entities: Campaign, Target, Negative, Placement, BudgetRule, ProductAd. The validator checks campaignId, adGroupId, targetId, bid, status, and parent links before applying.

- -
-
-

Validation preview

${state.bulkPreview.length} rows
-
- ${state.bulkPreview.map(r => ``).join('') || ''} -
RowEntityOperationStatusMessage
${r.row}${safe(r.entity || r.action)}${safe(r.operation || r.action)}${r.valid?'Valid':'Error'}${safe(r.message)}
Paste CSV and validate first.
-
-
-

Bulk ops teaching checks

Trainer prompts
-
-

Relationship check

Campaign is the parent. Ad groups, product ads, targets, negatives, budget rules, and report rows are children.

-

Before apply

Confirm account, parent IDs, row count, rollback copy, status changes, and bid or budget reason.

-

After apply

Open Integrity Center, check change history, compare before and after values, then export the trainer log.

-
-
`; - } - - function previewBulk() { - state.bulkPreview = parseBulk().map(validateBulkRow); - const ok = state.bulkPreview.length && state.bulkPreview.every(r => r.valid); - logAction('bulk_preview', `${state.bulkPreview.length} rows validated`, ok ? 'good' : 'warn'); - toast('Bulk sheet validated.', ok ? 'good' : 'warn'); render(); - } - - function applyBulk() { - if (!state.bulkPreview.length) state.bulkPreview = parseBulk().map(validateBulkRow); - const valid = state.bulkPreview.filter(r => r.valid); - valid.forEach(r => { - const c = campaignById(state, r.campaignId); if (!c) return; - normalizeCampaign(c); - if (r.action === 'campaign_budget' || r.action === 'campaign_update') { if (r.value) c.dailyBudget = Number(r.value); if (r.status) c.status = r.status; v31AppendHistory(c, 'Bulk updated campaign.'); } - if (r.action === 'target_bid' || r.action === 'target_update') { const t = v31FindBulkTarget(c, r); if (t) { if (r.bid) t.bid = Number(r.bid); if (r.status) t.status = r.status; v31AppendHistory(c, 'Bulk updated target ' + t.value + '.'); } } - if (r.action === 'target_pause') { const t = v31FindBulkTarget(c, r); if (t) { t.status = 'Paused'; v31AppendHistory(c, 'Bulk paused target ' + t.value + '.'); } } - if (r.action === 'target_create') { c.targets.push(metricDefaults({ id:uid('T'), campaignId:c.id, adGroupId:r.adGroupId || c.adGroups[0].id, type:r.type || 'Keyword', value:r.value || r.target, match:r.type || 'Exact', bid:Number(r.bid), status:r.status || 'Enabled' })); v31AppendHistory(c, 'Bulk created target ' + (r.value || r.target) + '.'); } - if (r.action === 'negative_exact' || r.action === 'negative_phrase' || r.action === 'negative_create') { c.negatives.push({ id:uid('NEG'), campaignId:c.id, adGroupId:r.adGroupId || c.adGroups[0].id, type:r.type || (r.action === 'negative_phrase' ? 'Negative phrase' : 'Negative exact'), value:r.value || r.target }); v31AppendHistory(c, 'Bulk added negative ' + (r.value || r.target) + '.'); } - if (r.action === 'placement_update') { c.placements[r.placement] = Number(r.percentage); v31AppendHistory(c, 'Bulk updated ' + r.placement + ' placement to ' + r.percentage + '%.'); } - if (r.action === 'budget_rule_create') { c.budgetRules.push({ id:uid('BR'), campaignId:c.id, name:r.value || 'Bulk budget rule', type:r.type || 'Schedule', increase:Number(r.percentage || r.value || 1), condition:r.condition || 'Bulk training rule' }); v31AppendHistory(c, 'Bulk created budget rule.'); } - if (r.action === 'product_ad_pause') { const ad = c.ads.find(x => x.id === r.adId); if (ad) { ad.status = 'Paused'; v31AppendHistory(c, 'Bulk paused product ad ' + ad.asin + '.'); } } - }); - normalizeState(); - const all = valid.length === state.bulkPreview.length; - logAction('bulk_apply', `${valid.length} valid rows applied of ${state.bulkPreview.length}`, all ? 'good' : 'warn'); - toast(`${valid.length} bulk rows applied to sandbox.`, valid.length ? (all ? 'good' : 'warn') : 'bad'); render(); - } - - function downloadBulkTemplate() { - downloadText('ppc-simulator-v3-bulk-template.csv', V31_BULK_TEMPLATE.replace(/\\n/g, '\n'), 'text/csv'); - logAction('download_bulk_template', 'V3 bulk template downloaded', 'good'); toast('V3 bulk template downloaded.', 'good'); - } - - function exportCsv() { - const rows = v31ReportRows(); - const headers = rows.length ? Object.keys(rows[0]) : ['empty']; - const csvRows = [headers].concat(rows.map(r => headers.map(h => r[h]))); - downloadText('ppc-simulator-' + String(state.reportType || 'report').toLowerCase().replace(/[^a-z0-9]+/g, '-') + '.csv', v31Csv(csvRows), 'text/csv'); - logAction('export_report', state.reportType, 'good'); toast('CSV report exported.', 'good'); - } - - function copyReport() { - const rows = v31ReportRows(); - const headers = rows.length ? Object.keys(rows[0]) : ['empty']; - const text = [headers].concat(rows.map(r => headers.map(h => r[h]))).map(r => r.join('\t')).join('\n'); - navigator.clipboard?.writeText(text); logAction('copy_report', state.reportType, 'good'); toast('Report rows copied.', 'good'); - } - - function integrityChecks() { - const checks = []; - const add = (severity, entity, message, fix='') => checks.push({ severity, entity, message, fix }); - const productSet = new Set(products.map(p => p.asin)); - const campaignIds = new Set(), agIds = new Set(), adIds = new Set(), targetIds = new Set(); - state.campaigns.forEach(c => { - if (campaignIds.has(c.id)) add('error', c.id, 'Duplicate campaign ID detected.', 'Run self-heal or duplicate again.'); - campaignIds.add(c.id); - if (!c.adGroups || !c.adGroups.length) add('error', c.id, 'Campaign has no ad group.', 'Run self-heal.'); - if (c.dailyBudget <= 0) add('error', c.id, 'Daily budget is not valid.', 'Set a daily budget above zero.'); - if (c.endDate && c.startDate && new Date(c.endDate) < new Date(c.startDate)) add('error', c.id, 'End date is before start date.', 'Move end date after start date.'); - (c.products || []).forEach(asin => { if (!productSet.has(asin)) add('error', c.id, `Product ASIN ${asin} does not exist.`, 'Use a valid catalog ASIN.'); if (v31RetailStatus(asin) === 'Low Inventory' && c.status === 'Enabled') add('warn', c.id, `${asin} is low inventory while campaign is enabled.`, 'Pause scaling or reduce budget.'); }); - const localAgIds = new Set((c.adGroups || []).map(ag => ag.id)); - (c.adGroups || []).forEach(ag => { if (agIds.has(ag.id)) add('error', ag.id, 'Duplicate ad group ID detected.', 'Regenerate child IDs.'); agIds.add(ag.id); if (ag.campaignId !== c.id) add('error', ag.id, 'Ad group points to wrong campaign.', 'Run self-heal.'); }); - (c.ads || []).forEach(ad => { if (adIds.has(ad.id)) add('error', ad.id, 'Duplicate product ad ID detected.', 'Regenerate child IDs.'); adIds.add(ad.id); if (ad.campaignId !== c.id) add('error', ad.id, 'Product ad points to wrong campaign.', 'Run self-heal.'); if (!localAgIds.has(ad.adGroupId)) add('error', ad.id, 'Product ad has missing ad group reference.', 'Attach to valid ad group.'); if (!productSet.has(ad.asin)) add('error', ad.id, 'Product ad ASIN is not in catalog.', 'Use valid ASIN.'); }); - (c.targets || []).forEach(t => { if (targetIds.has(t.id)) add('error', t.id, 'Duplicate target ID detected.', 'Regenerate child IDs.'); targetIds.add(t.id); if (t.campaignId !== c.id) add('error', t.id, 'Target points to wrong campaign.', 'Run self-heal.'); if (!localAgIds.has(t.adGroupId)) add('error', t.id, 'Target has missing ad group reference.', 'Attach to valid ad group.'); if (Number(t.bid) <= 0) add('error', t.id, 'Target bid is not valid.', 'Set bid above zero.'); }); - if (c.type === 'SP' && c.targetingMode === 'Automatic') ['Close match','Loose match','Substitutes','Complements'].forEach(g => { if (!c.targets.some(t => t.type === 'Auto' && t.value === g)) add('warn', c.id, 'SP auto group missing: ' + g + '.', 'Run self-heal or add target group.'); }); - (c.searchTerms || []).forEach(st => { if (st.campaignId !== c.id) add('error', st.id, 'Search term points to wrong campaign.', 'Run self-heal.'); if (c.type === 'SD') add('warn', st.id, 'Sponsored Display has search term rows.', 'Use audience/contextual reports for SD.'); if (c.type !== 'SD' && !st.targetId) add('warn', st.id, `Search term "${st.term}" has no target link.`, 'Attach to matched target.'); }); - const negKeys = new Set(); - (c.negatives || []).forEach(n => { const key = `${n.type}|${String(n.value).toLowerCase()}`; if (negKeys.has(key)) add('warn', n.id, 'Duplicate negative targeting row.', 'Remove duplicate negative.'); negKeys.add(key); if (n.campaignId !== c.id) add('error', n.id, 'Negative points to wrong campaign.', 'Run self-heal.'); }); - (c.budgetRules || []).forEach(r => { if (r.campaignId !== c.id) add('error', r.id, 'Budget rule points to wrong campaign.', 'Run self-heal.'); if (Number(r.increase) <= 0 || Number(r.increase) > 200) add('warn', r.id, 'Budget rule increase outside safe training range.', 'Use 1% to 200%.'); }); - if (c.type === 'SB') { if (c.adFormat === 'Product collection' && c.products.length < 3) add('error', c.id, 'SB Product Collection needs at least three products.', 'Select three products.'); if (c.adFormat === 'Video' && !(c.creative && c.creative.video)) add('error', c.id, 'SB Video has no video placeholder.', 'Add video placeholder.'); if (c.creativeStatus === 'Rejected') add('warn', c.id, `Creative rejected: ${c.creativeIssue || 'review needed'}`, 'Use Fix creative approval.'); } - if (c.type === 'SD' && !(String(c.targetingMode).includes('Contextual') || String(c.targetingMode).includes('Audiences'))) add('error', c.id, 'SD targeting mode is not contextual or audience-based.', 'Choose contextual or audience targeting.'); - }); - return checks; - } - - function autoRepairState() { - normalizeState(); - const used = { c:new Set(), ag:new Set(), ad:new Set(), t:new Set() }; - state.campaigns.forEach(c => { - if (used.c.has(c.id)) c.id = uid('C-' + c.type); used.c.add(c.id); - normalizeCampaign(c); - c.adGroups.forEach(ag => { if (used.ag.has(ag.id)) ag.id = uid('AG'); used.ag.add(ag.id); ag.campaignId = c.id; }); - const primary = c.adGroups[0].id; - c.ads.forEach(ad => { if (used.ad.has(ad.id)) ad.id = uid('AD'); used.ad.add(ad.id); ad.campaignId = c.id; if (!c.adGroups.some(ag => ag.id === ad.adGroupId)) ad.adGroupId = primary; }); - c.targets.forEach(t => { if (used.t.has(t.id)) t.id = uid('T'); used.t.add(t.id); t.campaignId = c.id; if (!c.adGroups.some(ag => ag.id === t.adGroupId)) t.adGroupId = primary; }); - c.searchTerms.forEach(st => { st.campaignId = c.id; st.adGroupId = c.adGroups.some(ag => ag.id === st.adGroupId) ? st.adGroupId : primary; st.targetId = linkSearchTermTarget(c, st); }); - const seenNeg = new Set(); - c.negatives = c.negatives.filter(n => { n.campaignId = c.id; if (!c.adGroups.some(ag => ag.id === n.adGroupId)) n.adGroupId = primary; const key = `${n.type}|${String(n.value).toLowerCase()}`; if (seenNeg.has(key)) return false; seenNeg.add(key); return true; }); - c.budgetRules.forEach(r => r.campaignId = c.id); - if (c.status === 'Archived') { c.adGroups.forEach(ag => ag.status = 'Archived'); c.ads.forEach(ad => ad.status = 'Archived'); c.targets.forEach(t => t.status = 'Archived'); } - }); - state.integrityLastRun = new Date().toISOString(); - logAction('integrity_repair', 'Relationship self-heal completed', 'good'); - toast('Integrity self-heal completed.', 'good'); - render(); - } - - function certificationProgress() { - return { - spBuild: state.campaigns.some(c => c.createdBySimulator && c.type === 'SP'), - sbBuild: state.campaigns.some(c => c.createdBySimulator && c.type === 'SB'), - sdBuild: state.campaigns.some(c => c.createdBySimulator && c.type === 'SD'), - optimization: state.actionLog.some(a => ['negative_added','harvest_term','bid_down','pause_target','budget_change'].includes(a.type)), - report: state.actionLog.some(a => ['export_report','copy_report','request_report'].includes(a.type)), - bulk: state.actionLog.some(a => a.type === 'bulk_apply'), - integrity: state.actionLog.some(a => ['integrity_repair','export_object_map'].includes(a.type)) - }; - } - - function trainerMetrics() { - const total = state.actionLog.length; - const good = state.actionLog.filter(a => a.quality === 'good').length; - const warn = state.actionLog.filter(a => a.quality === 'warn').length; - const bad = state.actionLog.filter(a => a.quality === 'bad').length; - const score = total ? Math.max(0, Math.round((good * 100 + warn * 55 - bad * 45) / total)) : 0; - return { total, good, warn, bad, score: Math.min(100, score), cert: certificationProgress() }; - } - - function renderTrainerDashboard() { - const tm = trainerMetrics(); - const rows = state.actionLog.slice().reverse().slice(0, 80); - const certItems = [['SP build', tm.cert.spBuild], ['SB build', tm.cert.sbBuild], ['SD build', tm.cert.sdBuild], ['Optimization action', tm.cert.optimization], ['Report action', tm.cert.report], ['Bulk apply', tm.cert.bulk], ['Integrity QA', tm.cert.integrity]]; - const pass = certItems.every(x => x[1]) && tm.score >= 75 && tm.bad === 0; - return `${pageTitle('Trainer dashboard', 'Track trainee actions, review mistakes, export proof of practice, and run certification-style sessions.', '')} -
-
${metricCard('Operator score', tm.score + '%', 'Good actions minus risky actions', tm.score >= 75 ? 'good' : 'bad')}${metricCard('Good actions', tm.good, 'Validated safe moves', 'good')}${metricCard('Review actions', tm.warn, 'Needs trainer follow-up')}${metricCard('Risky actions', tm.bad, 'High-risk or wrong-context moves', 'bad')}
-
-

Certification checklist

${pass ? 'Pass-ready' : 'Incomplete'}
${certItems.map(x => `
${x[1]?'✓':''}
${safe(x[0])}
`).join('')}
Status: ${pass ? 'Ready to pass after oral reasoning review.' : 'Complete each module, keep score at 75% or higher, and clear risky actions.'}
-

Trainer notes

Saved locally
-
-

Action review log

${rows.length} recent actions
${rows.map(a => ``).join('') || ''}
TimeActionDetailGradeTrainer prompt
${fmt.date(a.time)}${safe(a.type)}${safe(a.detail)}${safe(a.quality || 'review')}${safe(a.feedback || 'Ask for reasoning.')}
No actions yet. Start a mission or change a campaign.
`; - } - - function renderDocumentation() { - return `${pageTitle('Documentation', 'Living product notes for the Amazon PPC Training Simulator V3.2.', '')} -
-

What this simulator trains

  • SP setup, automatic target groups, manual keyword/product targeting, negatives, harvesting, placements, budgets, and reports.
  • SB setup with Product Collection, Store Spotlight, Video, creative fields, destinations, review states, and targeting.
  • SD setup with contextual targeting, views remarketing, purchases remarketing, creative, products, and audience logic.
  • Campaign manager navigation, report interpretation, bulk operations, Integrity Center, and trainer certification.
-

What changed in V3.2

  • Hardened campaign, ad group, product ad, target, negative, search term, creative, budget rule, and history relationships.
  • Added stronger Integrity Center checks for duplicate IDs, broken parent links, missing SP auto groups, and SD search-term mistakes.
  • Rebuilt bulk operations around entity-operation rows with parent-link validation.
  • Updated exports so report CSVs match the selected report type.
  • Added trainer certification checklist and stricter action grading.
  • Updated launch, duplicate, pause, archive, harvest, negative, and bid flows to maintain relationships.
-

Recommended training path

  • Day 1: navigation map, glossary, product readiness, and campaign filters.
  • Day 2: SP Auto, Manual Keyword, and Product Targeting builds.
  • Day 3: search term harvesting, negative exact, negative phrase, and bid reviews.
  • Day 4: SB creative formats, Store destinations, and video requirements.
  • Day 5: SD contextual and remarketing setup, plus why SD is not a search-term workflow.
  • Day 6: reports, bulk ops, Integrity Center, trainer review, and certification.
-

Known limits

  • No real Amazon connection, Seller Central access, or live bulk upload.
  • No multi-user backend. Progress saves in the local browser.
  • Metrics are simulated for training decisions, not forecasting.
  • The UI is inspired by Amazon Ads Console workflows but is not an exact clone.
-
`; - } - - function renderSettings() { - return `${pageTitle('Simulator settings', 'Reset data, toggle hints, and control training difficulty.', '')} -
-

Training controls

Local browser state


-

Build notes

Version 3.2

This static simulator saves progress in your browser with LocalStorage. V3.2 includes hardened object relationships, Integrity Center QA, randomized scenarios, stronger bulk validation, expanded reports, trainer certification, and improved action grading.

-
`; - } - - function exportDocs() { - const md = `# Amazon PPC Training Simulator V3.2 Documentation\n\n## Purpose\nTrain virtual assistants on Amazon PPC navigation and operations without Seller Central or Amazon Ads access.\n\n## Object model\n- Portfolio -> Campaign\n- Campaign -> Ad group\n- Ad group -> Product ad\n- Ad group -> Target\n- Campaign/ad group -> Negative\n- Target -> Search term row when a match exists\n- Campaign -> Creative for SB and SD\n- Campaign -> Budget rule\n- Campaign -> Change history\n\n## V3.2 improvements\n- Relationship hardening for launch, duplicate, pause, archive, harvesting, negatives, bids, and bulk apply.\n- Integrity Center checks for duplicate IDs, broken parent links, missing child objects, SD search-term mistakes, retail readiness risks, and SB creative requirements.\n- Entity-operation bulk sheet with parent validation before apply.\n- Report exports now match the selected report type.\n- Trainer dashboard now includes a certification checklist.\n- Scenario generator remains available for repeatable VA drills.\n\n## Training path\n1. Navigation and glossary.\n2. SP setup and optimization.\n3. SB creative and Store workflows.\n4. SD contextual and audience workflows.\n5. Reports and bulk operations.\n6. Integrity Center and trainer certification.\n\n## Safety limit\nThis simulator is a sandbox. It does not connect to Amazon systems, Seller Central, Amazon Ads, or live accounts. Metrics are simulated and intended for training only.\n`; - downloadText('amazon-ppc-simulator-v3.2-docs.md', md, 'text/markdown'); - logAction('export_docs', 'V3.2 documentation exported', 'good'); toast('Documentation exported.', 'good'); - } - - function evaluateAction(type, detail, quality) { - if (quality) return { tone:quality, message:quality === 'good' ? 'Good operator move. Ask for the reasoning and rollback path.' : quality === 'bad' ? 'Risky action. Review context before repeating.' : 'Needs trainer review.' }; - if (type === 'negative_added') { - const term = String(detail).split('|')[0].trim(); - const found = v31FindSearchTerm(term); - if (found && found.searchTerm.orders > 0) return { tone:'bad', message:'Risky negative. This term has orders.' }; - if (found && found.searchTerm.clicks >= 20 && found.searchTerm.sales === 0) return { tone:'good', message:'Good negative candidate. Spend exists without sales.' }; - } - if (type === 'harvest_term') { - const term = String(detail).split('|')[0].trim(); - const found = v31FindSearchTerm(term); - if (found && found.searchTerm.orders > 0) return { tone:'good', message:'Good harvest. Converting query moved into controlled targeting.' }; - return { tone:'bad', message:'Do not harvest non-converting terms as winners.' }; - } - const good = ['budget_rule_created','campaign_created','export_report','copy_report','request_report','simulation_run','bulk_apply','integrity_repair','export_object_map','download_bulk_template','trainer_notes_saved','trainee_saved']; - const warn = ['bid_up','placement_change','budget_change','status_change','duplicate_campaign','settings_change','bulk_preview','open_tab','open_campaign']; - const bad = ['archive_campaign']; - if (good.includes(type)) return { tone:'good', message:'Good training action. Explain why it was safe.' }; - if (warn.includes(type)) return { tone:'warn', message:'Requires context. Check ACOS, CVR, margin, inventory, goal, and approval level.' }; - if (bad.includes(type)) return { tone:'bad', message:'High-risk action. Archive only after approval and rollback review.' }; - return { tone:'warn', message:'Action logged. Trainer should ask for reasoning.' }; - } - - function resetAll() { - state.campaigns = copy(initialCampaigns); state.selectedCampaignId=''; state.selectedTab='campaigns'; state.filterType='All'; state.filterStatus='All'; state.search=''; state.draft=makeDraft(); state.wizardStep=1; state.actionLog=[]; state.feedbackLog=[]; state.bulkPreview=[]; state.bulkInput=V31_BULK_TEMPLATE; state.activeScenarioId=''; state.generatedScenarios=[]; state.reportQueue=[]; normalizeState(); toast('Simulator reset.', 'warn'); render(); - } - - if (!state.bulkInput || !state.bulkInput.includes('entity,operation')) state.bulkInput = V31_BULK_TEMPLATE; - - - /* V3.2 final hardening layer: clean relationships, stricter grading, safer bulk behavior */ - V31_BULK_TEMPLATE = 'entity,operation,campaignId,adGroupId,targetId,target,value,bid,type,status,placement,percentage,condition\nCampaign,Update,C-SP-AUTO-001,,,,42,,,,,,\nTarget,Update,C-SP-MAN-002,,T-005,stainless coffee filter,,1.32,,,,,\nTarget,Create,C-SP-MAN-002,AG-002,,,low intent coffee mug,0.31,Keyword,Enabled,,,\nNegative,Create,C-SP-AUTO-001,,,,paper coffee filters bulk,,Negative exact,,,,\nPlacement,Update,C-SP-MAN-002,,,,,,,,top,35,\nBudgetRule,Create,C-SP-MAN-002,,,,Prime Day pulse,,Schedule,,,25,Prime Day event week'; - - function v32Tone(q, fallback='warn') { - if (!q) return fallback; - if (typeof q === 'string') return q; - return q.tone || fallback; - } - - function v32Message(q, fallback='Needs trainer review.') { - if (!q) return fallback; - if (typeof q === 'string') return q === 'good' ? 'Good operator move. Ask for the reasoning and rollback path.' : q === 'bad' ? 'Risky action. Review context before repeating.' : fallback; - return q.message || fallback; - } - - function v32SafeToastTone(q) { - const tone = v32Tone(q); - return tone === 'bad' ? 'bad' : tone === 'warn' ? 'warn' : 'good'; - } - - function v32HistoryDetail(h) { - return typeof h === 'string' ? h : (h && h.detail) ? h.detail : String(h || ''); - } - - function renderHistoryTable(camps) { - const rows = camps.flatMap(c => (c.history || []).map(h => ({ c, h }))); - return `
- ${rows.map(({c,h}, i) => ``).join('')} - ${state.actionLog.slice().reverse().map(a => ``).join('')} -
TimestampCampaignChangeOperator
${fmt.date((h && h.time) || new Date(Date.now() - i * 86400000))}${safe(v32HistoryDetail(h))}${safe((h && h.actor) || 'Training VA')}
${fmt.date(a.time)}Simulator action${safe(a.type)}: ${safe(a.detail)}${safe(a.trainee || 'You')}
`; - } - - function evaluateAction(type, detail, quality) { - if (quality && typeof quality === 'object') return { tone:v32Tone(quality), message:v32Message(quality) }; - if (quality) return { tone:quality, message:v32Message(quality) }; - if (type === 'negative_added') { - const term = String(detail).split('|')[0].trim(); - const found = v31FindSearchTerm(term); - if (found && found.searchTerm.orders > 0) return { tone:'bad', message:'Risky negative. This term has orders.' }; - if (found && found.searchTerm.clicks >= 20 && found.searchTerm.sales === 0) return { tone:'good', message:'Good negative candidate. Spend exists without sales.' }; - } - if (type === 'harvest_term') { - const term = String(detail).split('|')[0].trim(); - const found = v31FindSearchTerm(term); - if (found && found.searchTerm.orders > 0) return { tone:'good', message:'Good harvest. Converting query moved into controlled targeting.' }; - return { tone:'bad', message:'Do not harvest non-converting terms as winners.' }; - } - const good = ['budget_rule_created','campaign_created','export_report','copy_report','request_report','simulation_run','bulk_apply','integrity_repair','export_object_map','download_bulk_template','trainer_notes_saved','trainee_saved','scenario_generated','mission_started','creative_repair','export_progress','import_progress']; - const warn = ['bid_up','bid_down','placement_change','budget_change','status_change','duplicate_campaign','settings_change','bulk_preview','open_tab','open_campaign']; - const bad = ['archive_campaign']; - if (good.includes(type)) return { tone:'good', message:'Good training action. Explain why it was safe and how to roll it back.' }; - if (warn.includes(type)) return { tone:'warn', message:'Requires context. Check ACOS, CVR, margin, inventory, goal, and approval level.' }; - if (bad.includes(type)) return { tone:'bad', message:'High-risk action. Archive only after approval and rollback review.' }; - return { tone:'warn', message:'Action logged. Trainer should ask for reasoning.' }; - } - - function launchCampaign() { - const d = copy(state.draft); - const result = validateDraftDetailed(d); - if (result.errors.length) { toast('Launch blocked. Fix validation errors first.', 'bad'); render(); return; } - const id = uid('C-' + d.type); - const ag = { id: uid('AG'), campaignId: id, name: d.type + ' training ad group', status: d.status, defaultBid: Number(d.defaultBid) }; - const targets = draftTargets(d).map(t => Object.assign(t, { campaignId: id, adGroupId: ag.id })); - const ads = uniqueList(d.products).map((asin, i) => ({ id:uid('AD'), campaignId:id, adGroupId:ag.id, asin, status:d.status === 'Archived' ? 'Archived' : 'Enabled', name:`${d.type} product ad ${i + 1}` })); - const c = { - id, type: d.type, name: d.name, portfolio: d.portfolio, status: d.status, dailyBudget: Number(d.dailyBudget), startDate: d.startDate, endDate: d.endDate, - targetingMode: d.targetingMode, adFormat: d.adFormat, bidStrategy: d.bidStrategy, defaultBid: Number(d.defaultBid), products: uniqueList(d.products), - creative: d.type === 'SP' ? null : copy(d.creative), creativeStatus: result.warnings.length ? 'Pending review' : 'Approved', creativeIssue: result.warnings.join(' '), - placements: copy(d.placements), budgetRules: [], negatives: [], metrics: { impressions:0, clicks:0, spend:0, sales:0, orders:0 }, adGroups: [ag], ads, targets, - searchTerms: d.type === 'SD' ? [] : seedSearchTerms(d, id, ag.id, targets), history: [], createdBySimulator: true - }; - v31AppendHistory(c, 'Campaign launched in simulator with normalized parent-child relationships.'); - normalizeCampaign(c); - state.campaigns.unshift(c); - normalizeState(); - state.selectedCampaignId = id; - state.selectedTab = 'overview'; - state.view = 'campaigns'; - state.draft = makeDraft(); - state.wizardStep = 1; - logAction('campaign_created', `${c.type} ${c.name}`, result.warnings.length ? { tone:'warn', message:result.warnings.join(' ') } : 'good'); - toast(`${campaignTypeLabel(c.type)} campaign launched in training sandbox.`, result.warnings.length ? 'warn' : 'good'); - } - - function duplicateCampaign(id) { - const c = campaignById(state, id); if (!c) return; - normalizeCampaign(c); - const n = copy(c); - const newId = uid('C-' + n.type); - const agMap = {}, targetMap = {}; - n.id = newId; n.name += ' copy'; n.status = 'Paused'; n.createdBySimulator = true; - n.metrics = { impressions:0, clicks:0, spend:0, sales:0, orders:0 }; - n.history = []; - n.adGroups = (n.adGroups || []).map(ag => { const old = ag.id, newAg = uid('AG'); agMap[old] = newAg; return Object.assign(ag, { id:newAg, campaignId:newId, status:'Paused' }); }); - if (!n.adGroups.length) n.adGroups = [{ id:uid('AG'), campaignId:newId, name:n.type + ' copied ad group', status:'Paused', defaultBid:n.defaultBid }]; - const primary = n.adGroups[0].id; - n.ads = (n.ads || []).map(ad => Object.assign(ad, { id:uid('AD'), campaignId:newId, adGroupId:agMap[ad.adGroupId] || primary, status:'Paused' })); - n.targets = (n.targets || []).map(t => { const old = t.id, newT = uid('T'); targetMap[old] = newT; return Object.assign(t, { id:newT, campaignId:newId, adGroupId:agMap[t.adGroupId] || primary, status:'Paused', impressions:0, clicks:0, spend:0, sales:0, orders:0 }); }); - n.searchTerms = []; - n.negatives = (n.negatives || []).map(neg => Object.assign(neg, { id:uid('NEG'), campaignId:newId, adGroupId:agMap[neg.adGroupId] || primary, sourceSearchTermId:'' })); - n.budgetRules = (n.budgetRules || []).map(r => Object.assign(r, { id:uid('BR'), campaignId:newId })); - if (n.creative) n.creativeStatus = 'Draft'; - v31AppendHistory(n, `Duplicated from ${c.id} as paused copy with regenerated child IDs and cleared report rows.`); - normalizeCampaign(n); - state.campaigns.unshift(n); - normalizeState(); - logAction('duplicate_campaign', `${c.name} copied to ${n.id}`, { tone:'good', message:'Duplicate created as paused clean copy with child relationships re-keyed.' }); - toast('Campaign duplicated as paused copy with fresh object IDs.', 'good'); render(); - } - - function toggleStatus(id) { - const c = campaignById(state, id); if (!c || c.status === 'Archived') return; - const next = c.status === 'Enabled' ? 'Paused' : 'Enabled'; - if (next === 'Enabled' && c.type !== 'SP' && c.creativeStatus === 'Rejected') { - logAction('status_change', `${c.name} blocked due to rejected creative`, { tone:'bad', message:'Cannot safely enable a rejected creative. Fix approval issue first.' }); - toast('Enable blocked. Fix rejected creative first.', 'bad'); render(); return; - } - c.status = next; - (c.adGroups || []).forEach(ag => ag.status = next); - (c.ads || []).forEach(ad => ad.status = next); - (c.targets || []).forEach(t => t.status = next); - v31AppendHistory(c, `Status changed to ${c.status} with child statuses updated.`); - const low = next === 'Enabled' && v31HasLowInventory(c); - logAction('status_change', `${c.name} ${c.status}`, low ? { tone:'warn', message:'Campaign enabled with low-inventory product. Confirm retail readiness before scaling.' } : 'good'); - toast(`${c.name} is now ${c.status}.`, low ? 'warn' : 'good'); render(); - } - - function harvestTerm(cid, term) { - const source = campaignById(state, cid); - const row = source && (source.searchTerms || []).find(x => String(x.term).toLowerCase() === String(term).toLowerCase()); - const manual = state.campaigns.find(c => c.id === 'C-SP-MAN-002') || state.campaigns.find(c => c.type === 'SP' && String(c.targetingMode).includes('Manual')); - if (!manual) { toast('No manual SP campaign found for harvesting.', 'bad'); return; } - normalizeCampaign(manual); - const agId = manual.adGroups[0].id; - const duplicate = manual.targets.some(t => t.value.toLowerCase() === String(term).toLowerCase() && t.match === 'Exact'); - if (!duplicate) { - manual.targets.push(Object.assign(makeTarget('Keyword', term, 'Exact', Math.max(manual.defaultBid, 0.9)), { campaignId: manual.id, adGroupId: agId })); - v31AppendHistory(manual, `Harvested search term as exact: ${term}`); - } - const q = duplicate ? { tone:'warn', message:'Exact target already exists. Do not create duplicate harvested keywords.' } : gradeHarvest(row); - logAction('harvest_term', `${term} | orders ${row ? row.orders : 0} | sales ${row ? row.sales : 0}`, q); - toast(duplicate ? `Exact keyword already exists: ${term}.` : `Harvested as exact keyword: ${term}.`, v32SafeToastTone(q)); - render(); - } - - function addNegative(cid, term, type) { - const c = campaignById(state, cid); if (!c) return; - normalizeCampaign(c); - const st = (c.searchTerms || []).find(x => String(x.term).toLowerCase() === String(term).toLowerCase()); - const duplicate = c.negatives.some(n => n.value.toLowerCase() === String(term).toLowerCase() && n.type === type); - if (!duplicate) c.negatives.push({ id:uid('NEG'), campaignId:c.id, adGroupId:c.adGroups[0].id, type, value:term, sourceSearchTermId:st ? st.id : '' }); - v31AppendHistory(c, duplicate ? `Skipped duplicate ${type}: ${term}` : `Added ${type}: ${term}`); - const q = gradeNegative(c, st, type, duplicate); - logAction('negative_added', `${term} | orders ${st ? st.orders : 'na'} | sales ${st ? st.sales : 'na'}`, q); - toast(duplicate ? `Duplicate ${type} skipped: ${term}.` : `Added ${type}: ${term}.`, v32SafeToastTone(q)); - render(); - } - - function adjustTargetBid(cid, tid, mult) { - const c = campaignById(state, cid); const t = c && c.targets.find(x => x.id === tid); if (!t) return; - const old = Number(t.bid || 0); - t.bid = Math.max(0.02, Math.round(t.bid * mult * 100) / 100); - const q = gradeBidMove(c, t, mult, old, t.bid); - v31AppendHistory(c, `Bid updated for ${t.value} from ${fmt.bid(old)} to ${fmt.bid(t.bid)}`); - logAction(mult > 1 ? 'bid_up' : 'bid_down', `${t.value} ${fmt.bid(old)} to ${fmt.bid(t.bid)} ACOS ${t.sales ? fmt.pct(calc(t).acos) : 'no sales'}`, q); - toast(`Bid updated for ${t.value}: ${fmt.bid(t.bid)}.`, v32SafeToastTone(q)); - render(); - } - - function pauseTarget(cid, tid) { - const c = campaignById(state, cid); const t = c && c.targets.find(x => x.id === tid); if (!t) return; - t.status = 'Paused'; - v31AppendHistory(c, `Paused target ${t.value}`); - const q = gradePauseTarget(t); - logAction('pause_target', t.value, q); - toast(`Paused target: ${t.value}.`, v32SafeToastTone(q)); - render(); - } - - function validateBulkRow(r) { - const c = campaignById(state, r.campaignId); - if (!c) return Object.assign(r, { valid:false, severity:'bad', message:'Campaign ID not found.' }); - normalizeCampaign(c); - if (r.action === 'campaign_budget' || r.action === 'campaign_update') { - if (r.status && !['Enabled','Paused','Archived'].includes(r.status)) return Object.assign(r, { valid:false, severity:'bad', message:'Campaign status must be Enabled, Paused, or Archived.' }); - if (r.status === 'Enabled' && c.type !== 'SP' && c.creativeStatus === 'Rejected') return Object.assign(r, { valid:false, severity:'bad', message:'Cannot enable a rejected creative campaign.' }); - if (r.value && Number(r.value) <= 0) return Object.assign(r, { valid:false, severity:'bad', message:'Budget value must be greater than zero.' }); - const q = r.value ? gradeBudgetChange(c, c.dailyBudget, Number(r.value)) : { tone:r.status === 'Archived' ? 'warn' : 'good', message:`Set status to ${r.status || c.status}.` }; - return Object.assign(r, { valid:true, severity:v32Tone(q), message:(r.value ? `Set budget to ${fmt.money(r.value)}. ` : `Update status. `) + v32Message(q) }); - } - if (r.action === 'target_bid' || r.action === 'target_update' || r.action === 'target_pause') { - const t = v31FindBulkTarget(c, r); - if (!t) return Object.assign(r, { valid:false, severity:'bad', message:'Target not found by targetId or target text.' }); - if ((r.action !== 'target_pause') && !(Number(r.bid) > 0)) return Object.assign(r, { valid:false, severity:'bad', message:'Bid must be greater than zero.' }); - if (r.status && !['Enabled','Paused','Archived'].includes(r.status)) return Object.assign(r, { valid:false, severity:'bad', message:'Invalid target status.' }); - r.targetId = t.id; - const q = r.action === 'target_pause' ? gradePauseTarget(t) : gradeBidMove(c, t, Number(r.bid) > Number(t.bid) ? 1.1 : 0.9, t.bid, Number(r.bid)); - return Object.assign(r, { valid:v32Tone(q) !== 'bad', severity:v32Tone(q), message:(r.action === 'target_pause' ? `Pause target ${t.value}. ` : `Update target ${t.value}. `) + v32Message(q) }); - } - if (r.action === 'target_create') { - const agId = r.adGroupId || c.adGroups[0].id; - if (!c.adGroups.some(ag => ag.id === agId)) return Object.assign(r, { valid:false, severity:'bad', message:'Ad group ID does not belong to campaign.' }); - if (!r.value && !r.target) return Object.assign(r, { valid:false, severity:'bad', message:'New target needs value or target.' }); - if (Number(r.bid) <= 0) return Object.assign(r, { valid:false, severity:'bad', message:'New target needs bid greater than zero.' }); - r.adGroupId = agId; - return Object.assign(r, { valid:true, severity:'good', message:'Create target under ad group ' + agId + '.' }); - } - if (r.action === 'negative_exact' || r.action === 'negative_phrase' || r.action === 'negative_create') { - const value = r.value || r.target; - if (!value) return Object.assign(r, { valid:false, severity:'bad', message:'Negative value is required.' }); - const type = r.type || (r.action === 'negative_phrase' ? 'Negative phrase' : 'Negative exact'); - const st = (c.searchTerms || []).find(x => String(x.term).toLowerCase() === String(value).toLowerCase()); - const duplicate = c.negatives.some(n => n.type === type && String(n.value).toLowerCase() === String(value).toLowerCase()); - const q = gradeNegative(c, st, type, duplicate); - r.value = value; r.type = type; - return Object.assign(r, { valid:v32Tone(q) !== 'bad', severity:v32Tone(q), message:'Add ' + type + ': ' + value + '. ' + v32Message(q) }); - } - if (r.action === 'placement_update') { - const p = String(r.placement || r.target || '').toLowerCase(); - const pct = Number(r.percentage || r.value || 0); - if (!['top','product','rest'].includes(p)) return Object.assign(r, { valid:false, severity:'bad', message:'Placement must be top, product, or rest.' }); - if (pct < 0 || pct > 900) return Object.assign(r, { valid:false, severity:'bad', message:'Placement percentage must be 0 to 900.' }); - r.placement = p; r.percentage = pct; - return Object.assign(r, { valid:true, severity:pct > 100 ? 'warn' : 'good', message:'Set ' + p + ' placement to ' + pct + '%.' }); - } - if (r.action === 'budget_rule_create') { - const pct = Number(r.percentage || 0); - if (!(pct > 0) || pct > 200) return Object.assign(r, { valid:false, severity:'bad', message:'Budget rule increase must be 1% to 200%.' }); - return Object.assign(r, { valid:true, severity:pct > 100 ? 'warn' : 'good', message:'Create budget rule at +' + pct + '%.' }); - } - if (r.action === 'product_ad_pause') { - const ad = c.ads.find(x => x.id === r.value || x.asin === r.value || x.asin === r.target); - if (!ad) return Object.assign(r, { valid:false, severity:'bad', message:'Product ad not found by ID or ASIN.' }); - r.adId = ad.id; - return Object.assign(r, { valid:true, severity:'warn', message:'Pause product ad ' + ad.asin + '. Confirm ASIN-level reason first.' }); - } - return Object.assign(r, { valid:false, severity:'bad', message:'Unsupported action or entity-operation pair.' }); - } - - function renderBulkOps() { - return `${pageTitle('Bulk operations', 'Practice bulk-sheet thinking with parent-child validation. Paste CSV, validate rows, preview changes, then apply to sandbox data.', '')} -
-

Bulk sheet input

V3.2 relationship mode
-

Supported entities: Campaign, Target, Negative, Placement, BudgetRule, ProductAd. The validator checks campaignId, adGroupId, targetId, bid, status, duplicate negatives, and parent links before applying.

- -
-
-

Validation preview

${state.bulkPreview.length} rows
-
- ${state.bulkPreview.map(r => ``).join('') || ''} -
RowEntityOperationStatusMessage
${r.row}${safe(r.entity || r.action)}${safe(r.operation || r.action)}${r.valid ? (r.severity === 'warn' ? 'Review' : 'Valid') : 'Error'}${safe(r.message)}
Paste CSV and validate first.
-
-
-

Bulk ops teaching checks

Trainer prompts
-
-

Relationship check

Campaign is the parent. Ad groups, product ads, targets, negatives, budget rules, and report rows are children.

-

Before apply

Confirm account, parent IDs, row count, rollback copy, status changes, and bid or budget reason.

-

After apply

Open Integrity Center, check change history, compare before and after values, then export the trainer log.

-
-
`; - } - - function previewBulk() { - state.bulkPreview = parseBulk().map(validateBulkRow); - const hasErrors = state.bulkPreview.some(r => !r.valid); - const hasWarnings = state.bulkPreview.some(r => r.valid && r.severity === 'warn'); - logAction('bulk_preview', `${state.bulkPreview.length} rows validated`, hasErrors ? 'bad' : hasWarnings ? 'warn' : 'good'); - toast('Bulk sheet validated.', hasErrors ? 'bad' : hasWarnings ? 'warn' : 'good'); render(); - } - - function applyBulk() { - if (!state.bulkPreview.length) state.bulkPreview = parseBulk().map(validateBulkRow); - const valid = state.bulkPreview.filter(r => r.valid); - valid.forEach(r => { - const c = campaignById(state, r.campaignId); if (!c) return; - normalizeCampaign(c); - if (r.action === 'campaign_budget' || r.action === 'campaign_update') { if (r.value) c.dailyBudget = Number(r.value); if (r.status) { c.status = r.status; (c.adGroups || []).forEach(ag => ag.status = r.status); (c.ads || []).forEach(ad => ad.status = r.status); (c.targets || []).forEach(t => t.status = r.status); } v31AppendHistory(c, 'Bulk updated campaign.'); } - if (r.action === 'target_bid' || r.action === 'target_update') { const t = v31FindBulkTarget(c, r); if (t) { if (r.bid) t.bid = Number(r.bid); if (r.status) t.status = r.status; v31AppendHistory(c, 'Bulk updated target ' + t.value + '.'); } } - if (r.action === 'target_pause') { const t = v31FindBulkTarget(c, r); if (t) { t.status = 'Paused'; v31AppendHistory(c, 'Bulk paused target ' + t.value + '.'); } } - if (r.action === 'target_create') { c.targets.push(metricDefaults({ id:uid('T'), campaignId:c.id, adGroupId:r.adGroupId || c.adGroups[0].id, type:r.type || 'Keyword', value:r.value || r.target, match:r.type || 'Exact', bid:Number(r.bid), status:r.status || 'Enabled' })); v31AppendHistory(c, 'Bulk created target ' + (r.value || r.target) + '.'); } - if (r.action === 'negative_exact' || r.action === 'negative_phrase' || r.action === 'negative_create') { if (!c.negatives.some(n => n.type === r.type && String(n.value).toLowerCase() === String(r.value || r.target).toLowerCase())) c.negatives.push({ id:uid('NEG'), campaignId:c.id, adGroupId:r.adGroupId || c.adGroups[0].id, type:r.type || (r.action === 'negative_phrase' ? 'Negative phrase' : 'Negative exact'), value:r.value || r.target }); v31AppendHistory(c, 'Bulk added negative ' + (r.value || r.target) + '.'); } - if (r.action === 'placement_update') { c.placements[r.placement] = Number(r.percentage); v31AppendHistory(c, 'Bulk updated ' + r.placement + ' placement to ' + r.percentage + '%.'); } - if (r.action === 'budget_rule_create') { c.budgetRules.push({ id:uid('BR'), campaignId:c.id, name:r.value || 'Bulk budget rule', type:r.type || 'Schedule', increase:Number(r.percentage || 1), condition:r.condition || 'Bulk training rule' }); v31AppendHistory(c, 'Bulk created budget rule.'); } - if (r.action === 'product_ad_pause') { const ad = c.ads.find(x => x.id === r.adId); if (ad) { ad.status = 'Paused'; v31AppendHistory(c, 'Bulk paused product ad ' + ad.asin + '.'); } } - }); - normalizeState(); - const all = valid.length === state.bulkPreview.length; - logAction('bulk_apply', `${valid.length} valid rows applied of ${state.bulkPreview.length}`, all ? 'good' : 'warn'); - toast(`${valid.length} bulk rows applied to sandbox.`, valid.length ? (all ? 'good' : 'warn') : 'bad'); render(); - } - - function downloadBulkTemplate() { - downloadText('ppc-simulator-v3.2-bulk-template.csv', V31_BULK_TEMPLATE.replace(/\\n/g, '\n'), 'text/csv'); - logAction('download_bulk_template', 'V3.2 bulk template downloaded', 'good'); toast('V3.2 bulk template downloaded.', 'good'); - } - - function integrityChecks() { - const checks = []; - const add = (severity, entity, message, fix='') => checks.push({ severity, entity, message, fix }); - const productSet = new Set(products.map(p => p.asin)); - const campaignIds = new Set(), agIds = new Set(), adIds = new Set(), targetIds = new Set(); - state.campaigns.forEach(c => { - if (campaignIds.has(c.id)) add('error', c.id, 'Duplicate campaign ID detected.', 'Run self-heal or duplicate again.'); - campaignIds.add(c.id); - if (!c.adGroups || !c.adGroups.length) add('error', c.id, 'Campaign has no ad group.', 'Run self-heal.'); - if (c.dailyBudget <= 0) add('error', c.id, 'Daily budget is not valid.', 'Set a daily budget above zero.'); - if (c.defaultBid <= 0) add('error', c.id, 'Default bid is not valid.', 'Set a bid above zero.'); - if (c.endDate && c.startDate && new Date(c.endDate) < new Date(c.startDate)) add('error', c.id, 'End date is before start date.', 'Move end date after start date.'); - (c.products || []).forEach(asin => { if (!productSet.has(asin)) add('error', c.id, `Product ASIN ${asin} does not exist.`, 'Use a valid catalog ASIN.'); if (v31RetailStatus(asin) === 'Low Inventory' && c.status === 'Enabled') add('warn', c.id, `${asin} is low inventory while campaign is enabled.`, 'Pause scaling or reduce budget.'); }); - const localAgIds = new Set((c.adGroups || []).map(ag => ag.id)); - (c.adGroups || []).forEach(ag => { if (agIds.has(ag.id)) add('error', ag.id, 'Duplicate ad group ID detected.', 'Regenerate child IDs.'); agIds.add(ag.id); if (ag.campaignId !== c.id) add('error', ag.id, 'Ad group points to wrong campaign.', 'Run self-heal.'); if (c.status === 'Archived' && ag.status !== 'Archived') add('error', ag.id, 'Archived campaign has active ad group.', 'Archive children with parent.'); }); - (c.ads || []).forEach(ad => { if (adIds.has(ad.id)) add('error', ad.id, 'Duplicate product ad ID detected.', 'Regenerate child IDs.'); adIds.add(ad.id); if (ad.campaignId !== c.id) add('error', ad.id, 'Product ad points to wrong campaign.', 'Run self-heal.'); if (!localAgIds.has(ad.adGroupId)) add('error', ad.id, 'Product ad has missing ad group reference.', 'Attach to valid ad group.'); if (!productSet.has(ad.asin)) add('error', ad.id, 'Product ad ASIN is not in catalog.', 'Use valid ASIN.'); if (c.status === 'Archived' && ad.status !== 'Archived') add('error', ad.id, 'Archived campaign has active product ad.', 'Archive children with parent.'); }); - (c.targets || []).forEach(t => { if (targetIds.has(t.id)) add('error', t.id, 'Duplicate target ID detected.', 'Regenerate child IDs.'); targetIds.add(t.id); if (t.campaignId !== c.id) add('error', t.id, 'Target points to wrong campaign.', 'Run self-heal.'); if (!localAgIds.has(t.adGroupId)) add('error', t.id, 'Target has missing ad group reference.', 'Attach to valid ad group.'); if (Number(t.bid) <= 0) add('error', t.id, 'Target bid is not valid.', 'Set bid above zero.'); if (c.status === 'Archived' && t.status !== 'Archived') add('error', t.id, 'Archived campaign has active target.', 'Archive children with parent.'); }); - if (c.type === 'SP' && c.targetingMode === 'Automatic') ['Close match','Loose match','Substitutes','Complements'].forEach(g => { if (!c.targets.some(t => t.type === 'Auto' && t.value === g)) add('warn', c.id, 'SP auto group missing: ' + g + '.', 'Run self-heal or add target group.'); }); - (c.searchTerms || []).forEach(st => { if (st.campaignId !== c.id) add('error', st.id, 'Search term points to wrong campaign.', 'Run self-heal.'); if (c.type === 'SD') add('warn', st.id, 'Sponsored Display has search term rows.', 'Use audience/contextual reports for SD.'); if (c.type !== 'SD' && !st.targetId) add('warn', st.id, `Search term "${st.term}" has no target link.`, 'Attach to matched target.'); }); - const negKeys = new Set(); - (c.negatives || []).forEach(n => { const key = `${n.type}|${String(n.value).toLowerCase()}`; if (negKeys.has(key)) add('warn', n.id, 'Duplicate negative targeting row.', 'Remove duplicate negative.'); negKeys.add(key); if (n.campaignId !== c.id) add('error', n.id, 'Negative points to wrong campaign.', 'Run self-heal.'); if (!localAgIds.has(n.adGroupId)) add('error', n.id, 'Negative has missing ad group reference.', 'Attach to valid ad group.'); }); - (c.budgetRules || []).forEach(r => { if (r.campaignId !== c.id) add('error', r.id, 'Budget rule points to wrong campaign.', 'Run self-heal.'); if (Number(r.increase) <= 0 || Number(r.increase) > 200) add('warn', r.id, 'Budget rule increase outside safe training range.', 'Use 1% to 200%.'); }); - if (c.type === 'SB') { if (c.adFormat === 'Product collection' && c.products.length < 3) add('error', c.id, 'SB Product Collection needs at least three products.', 'Select three products.'); if (c.adFormat === 'Store spotlight' && c.creative?.destination !== 'Brand Store') add('error', c.id, 'SB Store Spotlight should use Brand Store destination.', 'Switch destination to Brand Store.'); if (c.adFormat === 'Video' && !(c.creative && c.creative.video)) add('error', c.id, 'SB Video has no video placeholder.', 'Add video placeholder.'); if (c.creativeStatus === 'Rejected') add('warn', c.id, `Creative rejected: ${c.creativeIssue || 'review needed'}`, 'Use Fix creative approval.'); } - if (c.type === 'SD' && !(String(c.targetingMode).includes('Contextual') || String(c.targetingMode).includes('Audiences'))) add('error', c.id, 'SD targeting mode is not contextual or audience-based.', 'Choose contextual or audience targeting.'); - }); - return checks; - } - - function renderDocumentation() { - return `${pageTitle('Documentation', 'Living product notes for the Amazon PPC Training Simulator V3.2.', '')} -
-

What this simulator trains

  • SP setup, automatic target groups, manual keyword/product targeting, negatives, harvesting, placements, budgets, and reports.
  • SB setup with Product Collection, Store Spotlight, Video, creative fields, destinations, review states, and targeting.
  • SD setup with contextual targeting, views remarketing, purchases remarketing, creative, products, and audience logic.
  • Campaign manager navigation, report interpretation, bulk operations, Integrity Center, and trainer certification.
-

What changed in V3.2

  • Tightened campaign launch so search terms, ads, targets, and ad groups link immediately.
  • Fixed duplication so copies regenerate every child ID, clear historical report rows, and launch paused.
  • Improved bid, pause, negative, harvest, budget, and placement grading with performance context.
  • Strengthened bulk validation with duplicate negative checks, rejected creative blocking, and risky-action warnings.
  • Improved history rendering for structured change records.
  • Expanded Integrity Center checks for archived child objects and missing parent links.
-

Recommended training path

  • Day 1: navigation map, glossary, product readiness, and campaign filters.
  • Day 2: SP Auto, Manual Keyword, and Product Targeting builds.
  • Day 3: search term harvesting, negative exact, negative phrase, and bid reviews.
  • Day 4: SB creative formats, Store destinations, and video requirements.
  • Day 5: SD contextual and remarketing setup, plus why SD is not a search-term workflow.
  • Day 6: reports, bulk ops, Integrity Center, trainer review, and certification.
-

Known limits

  • No real Amazon connection, Seller Central access, or live bulk upload.
  • No multi-user backend. Progress saves in the local browser.
  • Metrics are simulated for training decisions, not forecasting.
  • The UI is inspired by Amazon Ads Console workflows but is not an exact clone.
-
`; - } - - function renderSettings() { - return `${pageTitle('Simulator settings', 'Reset data, toggle hints, and control training difficulty.', '')} -
-

Training controls

Local browser state


-

Build notes

Version ${APP_VERSION}

This static simulator saves progress in your browser with LocalStorage. V3.3 includes guided click-by-click navigation drills, hardened object relationships, Integrity Center QA, randomized scenarios, stronger bulk validation, expanded reports, trainer certification, structured history, and improved action grading.

-
`; - } - - function exportDocs() { - const md = `# Amazon PPC Training Simulator V3.2 Documentation\n\n## Purpose\nTrain virtual assistants on Amazon PPC navigation and operations without Seller Central or Amazon Ads access.\n\n## Object model\n- Portfolio -> Campaign\n- Campaign -> Ad group\n- Ad group -> Product ad\n- Ad group -> Target\n- Campaign/ad group -> Negative\n- Target -> Search term row when a match exists\n- Campaign -> Creative for SB and SD\n- Campaign -> Budget rule\n- Campaign -> Structured change history\n\n## V3.2 improvements\n- Launch now links ad groups, product ads, targets, search terms, negatives, and budget rules immediately.\n- Duplicate creates paused clean copies, regenerates child IDs, and clears historical search-term rows.\n- Bid, pause, harvest, negative, budget, and placement actions use contextual grading.\n- Bulk operations validate parent IDs, target IDs, duplicate negatives, rejected creatives, bids, budgets, placements, and budget rules.\n- Integrity Center checks duplicate IDs, broken parent links, missing child objects, SD search-term mistakes, retail readiness risks, archived-child status, and SB creative requirements.\n- Report exports match the selected report type.\n- Trainer dashboard includes certification checklist, action grades, notes, import/export, and logs.\n\n## Training path\n1. Navigation and glossary.\n2. SP setup and optimization.\n3. SB creative and Store workflows.\n4. SD contextual and audience workflows.\n5. Reports and bulk operations.\n6. Integrity Center and trainer certification.\n\n## QA checklist\n- Run Integrity Center after importing progress, generating scenarios, duplicating campaigns, or applying bulk rows.\n- Export the trainer log after each trainee session.\n- Ask the trainee to explain the data reason, risk, and rollback path before any live-account equivalent action.\n\n## Safety limit\nThis simulator is a sandbox. It does not connect to Amazon systems, Seller Central, Amazon Ads, or live accounts. Metrics are simulated and intended for training only.\n`; - downloadText('amazon-ppc-simulator-v3.2-docs.md', md, 'text/markdown'); - logAction('export_docs', 'V3.2 documentation exported', 'good'); toast('Documentation exported.', 'good'); - } - - function resetAll() { - state.campaigns = copy(initialCampaigns); - state.selectedCampaignId=''; state.selectedTab='campaigns'; state.filterType='All'; state.filterStatus='All'; state.search=''; - state.draft=makeDraft(); state.wizardStep=1; state.actionLog=[]; state.feedbackLog=[]; state.bulkPreview=[]; state.bulkInput=V31_BULK_TEMPLATE; state.activeScenarioId=''; state.generatedScenarios=[]; state.reportQueue=[]; state.selectedReportId=''; state.integrityLastRun=''; state.simulationDays=0; - normalizeState(); toast('Simulator reset.', 'warn'); render(); - } - - /* V3.3 guided navigation drill layer */ - const V33_NAVIGATION_DRILLS = [ - { - id:'nav-sp-search-term-negative', type:'SP', title:'Find and block waste from Search terms', difficulty:'Beginner', minutes:7, - summary:'Click through Campaign manager, open the SP Auto campaign, find Search terms, add a negative exact, then verify the negative tab.', - route:['Campaign manager','SP Auto campaign','Search terms','Negative exact','Negatives tab'], - steps:[ - { id:'go-campaign-manager', label:'Open Campaign manager', instruction:'Use the left navigation. Do not jump through another page.', selector:'[data-view="campaigns"]', done:s=>s.view==='campaigns' && !s.selectedCampaignId, coach:'The Campaign manager is the control tower. Every operator should start from the table, not vibes.' }, - { id:'open-auto-campaign', label:'Open the SP Auto discovery campaign', instruction:'Open the campaign named SP | Auto | Coffee Filter | Discovery.', selector:'[data-campaign="C-SP-AUTO-001"]', done:s=>s.view==='campaigns' && s.selectedCampaignId==='C-SP-AUTO-001', coach:'Auto campaigns are discovery engines. Review their query output before changing bids.' }, - { id:'open-search-terms', label:'Open the Search terms tab', instruction:'Click Search terms inside the campaign detail tabs.', selector:'[data-tab="searchTerms"]', done:s=>s.selectedCampaignId==='C-SP-AUTO-001' && s.selectedTab==='searchTerms', coach:'Search terms show what shoppers typed or matched into. This is where waste gets loud.' }, - { id:'add-negative-exact', label:'Add negative exact for the waste term', instruction:'Click Negative exact on paper coffee filters bulk.', selector:'[data-action="negative"][data-cid="C-SP-AUTO-001"][data-term="paper coffee filters bulk"]', done:s=>!!hasNegative(s,'C-SP-AUTO-001','paper coffee filters bulk'), coach:'Exact blocks the specific bad query. Phrase is stronger and riskier.' }, - { id:'verify-negatives', label:'Verify Negative targeting', instruction:'Open the Negative targeting tab to confirm the row exists.', selector:'[data-tab="negatives"]', done:s=>s.selectedCampaignId==='C-SP-AUTO-001' && s.selectedTab==='negatives' && !!hasNegative(s,'C-SP-AUTO-001','paper coffee filters bulk'), coach:'Trust but verify. PPC goblins love duplicate or missing negatives.' } - ] - }, - { - id:'nav-sp-placement-controls', type:'SP', title:'Find placement controls and save safely', difficulty:'Operator', minutes:6, - summary:'Navigate to a manual SP campaign, find Placements, save the controls, then verify the change history path.', - route:['Campaign manager','Manual campaign','Placements','Save','Change history'], - steps:[ - { id:'go-campaign-manager', label:'Open Campaign manager', instruction:'Start from the Campaign manager table.', selector:'[data-view="campaigns"]', done:s=>s.view==='campaigns' && !s.selectedCampaignId, coach:'Placement changes are campaign-level. Start from the campaign, not a random target row.' }, - { id:'open-manual-campaign', label:'Open the manual exact campaign', instruction:'Open SP | Manual | Coffee Filter | Exact Winners.', selector:'[data-campaign="C-SP-MAN-002"]', done:s=>s.selectedCampaignId==='C-SP-MAN-002', coach:'Manual campaigns usually hold winners. Placement changes here affect controlled traffic.' }, - { id:'open-placements-tab', label:'Open the Placements tab', instruction:'Click Placements in the campaign detail tabs.', selector:'[data-tab="placements"]', done:s=>s.selectedCampaignId==='C-SP-MAN-002' && s.selectedTab==='placements', coach:'Top of Search multipliers scale fast. Small mistakes here wear expensive shoes.' }, - { id:'save-placement-settings', label:'Save placement settings', instruction:'Click Save for the manual campaign placement row.', selector:'[data-action="savePlacements"][data-id="C-SP-MAN-002"]', allowSelectors:['input[data-id="C-SP-MAN-002"][data-place]'], done:s=>s.actionLog.some(a=>a.type==='placement_change' && String(a.detail||'').includes('SP | Manual | Coffee Filter | Exact Winners')), coach:'Saving without reading ACOS and placement report is not operator behavior. Explain the risk.' }, - { id:'open-history-tab', label:'Open Change history', instruction:'Click Change history to verify the saved action trail.', selector:'[data-tab="history"]', done:s=>s.selectedCampaignId==='C-SP-MAN-002' && s.selectedTab==='history', coach:'A good VA confirms what changed. Future-you deserves receipts.' } - ] - }, - { - id:'nav-sb-creative-review', type:'SB', title:'Find Sponsored Brands creative details', difficulty:'Brand Ads', minutes:5, - summary:'Use Creative assets, open an SB Video campaign, and inspect the overview where brand, headline, destination, and approval details live.', - route:['Creative assets','SB Video campaign','Overview','Targeting'], - steps:[ - { id:'open-creative-assets', label:'Open Creative assets', instruction:'Use the left navigation to open Creative assets.', selector:'[data-view="creative"]', done:s=>s.view==='creative', coach:'SB and SD work is partly creative QA. Do not treat every ad like SP keywords with nicer clothes.' }, - { id:'open-sb-video', label:'Open the SB Video campaign', instruction:'Open SB | Video | Milk Frother Demo from the creative campaign table.', selector:'[data-campaign="C-SB-VID-005"]', done:s=>s.selectedCampaignId==='C-SB-VID-005', coach:'Video campaigns need asset, destination, product, and targeting review before enabling.' }, - { id:'confirm-overview', label:'Confirm the Overview tab', instruction:'Click Overview if needed and inspect headline, brand, destination, and approval.', selector:'[data-tab="overview"]', done:s=>s.selectedCampaignId==='C-SB-VID-005' && s.selectedTab==='overview', coach:'The overview panel is where a trainer checks whether the VA sees the whole ad, not only metrics.' }, - { id:'open-targeting', label:'Open Targeting after creative review', instruction:'Click Targeting to inspect the keyword rows after confirming creative basics.', selector:'[data-tab="targets"]', done:s=>s.selectedCampaignId==='C-SB-VID-005' && s.selectedTab==='targets', coach:'Creative first, targeting second. Otherwise you might scale a beautiful ad pointed at nonsense.' } - ] - }, - { - id:'nav-report-request', type:'Reports', title:'Request and copy a report workflow', difficulty:'Reporting', minutes:5, - summary:'Navigate to Reports, request the current report, then copy report rows for spreadsheet practice.', - route:['Reports','Request report','Copy rows'], - steps:[ - { id:'open-reports', label:'Open Reports', instruction:'Use the left navigation to open Reports.', selector:'[data-view="reports"]', done:s=>s.view==='reports', coach:'Reports are where actions get evidence. No report, no hero moves.' }, - { id:'request-report', label:'Request a report', instruction:'Click Request report to add a new report queue row.', selector:'[data-action="requestReport"]', done:s=>Array.isArray(s.reportQueue) && s.reportQueue.length>0, coach:'The queue teaches trainees to think in report type, date range, and download history.' }, - { id:'copy-report', label:'Copy report rows', instruction:'Click Copy report rows for spreadsheet-ready practice data.', selector:'[data-action="copyReport"]', done:s=>s.actionLog.some(a=>a.type==='copy_report'), coach:'Copying rows is harmless in the simulator. In live work, file naming and date range control matter.' } - ] - }, - { - id:'nav-sd-audience-path', type:'SD', title:'Find Sponsored Display audience targeting', difficulty:'Display Ads', minutes:6, - summary:'Open an SD audience campaign and inspect Targeting without looking for Search terms.', - route:['Campaign manager','SD audience campaign','Targeting','Reports'], - steps:[ - { id:'go-campaign-manager', label:'Open Campaign manager', instruction:'Use the left navigation to open Campaign manager.', selector:'[data-view="campaigns"]', done:s=>s.view==='campaigns' && !s.selectedCampaignId, coach:'Display still starts in Campaign manager, but the mental model changes.' }, - { id:'open-sd-audience', label:'Open the SD Views Remarketing campaign', instruction:'Open SD | Views Remarketing | 30 Day.', selector:'[data-campaign="C-SD-AUD-007"]', done:s=>s.selectedCampaignId==='C-SD-AUD-007', coach:'This campaign targets audiences, not shopper search terms.' }, - { id:'open-sd-targeting', label:'Open Targeting', instruction:'Click Targeting and inspect audience rows.', selector:'[data-tab="targets"]', done:s=>s.selectedCampaignId==='C-SD-AUD-007' && s.selectedTab==='targets', coach:'For SD, train the VA to read contextual and audience targets instead of hunting for keyword match types.' }, - { id:'open-reports-after-sd', label:'Open Reports after SD review', instruction:'Use Reports for follow-up analysis instead of Search terms.', selector:'[data-view="reports"]', done:s=>s.view==='reports' && s.selectedCampaignId==='', coach:'Sponsored Display reporting is not the same as SP Search terms. Tiny distinction, expensive consequences.' } - ] - } - ]; - - function v33EnsureState() { - if (typeof state.activeNavigationDrillId !== 'string') state.activeNavigationDrillId = ''; - if (typeof state.navigationDrillStep !== 'number') state.navigationDrillStep = 0; - if (typeof state.navigationDrillMistakes !== 'number') state.navigationDrillMistakes = 0; - if (typeof state.navigationDrillSkips !== 'number') state.navigationDrillSkips = 0; - if (!Array.isArray(state.navigationDrillLog)) state.navigationDrillLog = []; - if (!Array.isArray(state.navigationDrillResults)) state.navigationDrillResults = []; - if (typeof state.navigationDrillStartedAt !== 'string') state.navigationDrillStartedAt = ''; - if (typeof state.navigationDrillCompleted !== 'boolean') state.navigationDrillCompleted = false; - if (state.activeNavigationDrillId && !v33NavigationDrillById(state.activeNavigationDrillId)) { - state.activeNavigationDrillId = ''; - state.navigationDrillStep = 0; - state.navigationDrillCompleted = false; - } - } - - function v33NavigationDrillById(id) { return V33_NAVIGATION_DRILLS.find(d => d.id === id); } - function v33ActiveNavigationDrill() { return v33NavigationDrillById(state.activeNavigationDrillId); } - function v33CurrentNavigationStep() { - const d = v33ActiveNavigationDrill(); - if (!d || state.navigationDrillCompleted) return null; - return d.steps[state.navigationDrillStep] || null; - } - function v33NavigationPct(drill) { - const total = drill ? drill.steps.length : 0; - const done = Math.min(state.navigationDrillStep || 0, total); - return total ? Math.round(done / total * 100) : 0; - } - function v33NavigationScore() { - const penalty = (state.navigationDrillMistakes || 0) * 12 + (state.navigationDrillSkips || 0) * 8; - return Math.max(0, Math.min(100, 100 - penalty)); - } - function v33StepState(drill, index) { - if (!drill) return ''; - if (index < state.navigationDrillStep) return 'done'; - if (index === state.navigationDrillStep && !state.navigationDrillCompleted) return 'current'; - return 'muted-step'; - } - function v33RenderDrillStepList(drill) { - if (!drill) return ''; - return drill.steps.map((step, i) => ``).join(''); - } - function v33DrillCard(drill) { - const active = drill.id === state.activeNavigationDrillId; - const pct = active ? v33NavigationPct(drill) : 0; - return `
-
${safe(drill.type)}${safe(drill.difficulty)}${drill.minutes} min
-

${safe(drill.title)}

-

${safe(drill.summary)}

-
${drill.route.map(x=>`${safe(x)}`).join('')}
- ${active ? `

Active progress: ${state.navigationDrillStep}/${drill.steps.length}. Mistakes: ${state.navigationDrillMistakes || 0}. Skips: ${state.navigationDrillSkips || 0}.

` : ''} - -
`; - } - function renderNavigationDrillsPage() { - v33EnsureState(); - const active = v33ActiveNavigationDrill(); - const results = (state.navigationDrillResults || []).slice().reverse().slice(0, 12); - return `${pageTitle('Guided navigation drills', 'Click-by-click route training with target highlighting, wrong-click blocking, scoring, and trainer review.', active ? '' : '')} - ${active ? `` : `
How it works:
Start a drill, then click only the highlighted element. Wrong clicks are blocked and logged so trainers can see navigation uncertainty without risking a live account.
`} -
${V33_NAVIGATION_DRILLS.map(v33DrillCard).join('')}
-

Recent drill results

${results.length}
- ${results.length ? `
${results.map(r=>``).join('')}
CompletedDrillTraineeScoreMistakesSkips
${fmt.date(r.completedAt)}${safe(r.title)}${safe(r.trainee)}${r.score}%${r.mistakes}${r.skips}
` : '

No completed navigation drills yet.

'} -
`; - } - function v33RenderNavigationDrillRail() { - v33EnsureState(); - const drill = v33ActiveNavigationDrill(); - if (!drill) return ''; - const step = v33CurrentNavigationStep(); - const pct = v33NavigationPct(drill); - if (state.navigationDrillCompleted) { - return ``; - } - return ``; - } - function v33StartNavigationDrill(id) { - const drill = v33NavigationDrillById(id); - if (!drill) { toast('Navigation drill not found.', 'bad'); return; } - state.activeNavigationDrillId = drill.id; - state.navigationDrillStep = 0; - state.navigationDrillMistakes = 0; - state.navigationDrillSkips = 0; - state.navigationDrillLog = []; - state.navigationDrillStartedAt = new Date().toISOString(); - state.navigationDrillCompleted = false; - logAction('navigation_drill_started', drill.title, { tone:'good', message:'Guided navigation drill started. Follow the highlighted path.' }); - v33EvaluateNavigationDrill(false); - toast(`Navigation drill started: ${drill.title}.`, 'good'); - render(); - } - function v33StopNavigationDrill() { - const drill = v33ActiveNavigationDrill(); - if (drill) logAction('navigation_drill_stopped', drill.title, { tone:'warn', message:'Navigation drill stopped before completion.' }); - state.activeNavigationDrillId = ''; - state.navigationDrillCompleted = false; - render(); - } - function v33SkipNavigationStep() { - const drill = v33ActiveNavigationDrill(); - const step = v33CurrentNavigationStep(); - if (!drill || !step) return; - state.navigationDrillSkips = (state.navigationDrillSkips || 0) + 1; - state.navigationDrillLog.unshift({ type:'skip', drillId:drill.id, stepId:step.id, label:step.label, time:new Date().toISOString(), trainee:state.traineeName }); - logAction('navigation_step_skipped', `${drill.title}: ${step.label}`, { tone:'warn', message:'Step skipped. Trainer should ask what path the VA missed.' }); - state.navigationDrillStep += 1; - if (state.navigationDrillStep >= drill.steps.length) v33CompleteNavigationDrill(drill); - else render(); - } - function v33EvaluateNavigationDrill(quiet) { - v33EnsureState(); - const drill = v33ActiveNavigationDrill(); - if (!drill || state.navigationDrillCompleted) return; - let advanced = false; - while (state.navigationDrillStep < drill.steps.length) { - const step = drill.steps[state.navigationDrillStep]; - let done = false; - try { done = !!step.done(state); } catch (err) { done = false; } - if (!done) break; - state.navigationDrillLog.unshift({ type:'step_complete', drillId:drill.id, stepId:step.id, label:step.label, time:new Date().toISOString(), trainee:state.traineeName }); - logAction('navigation_step_complete', `${drill.title}: ${step.label}`, { tone:'good', message:'Correct navigation click.' }); - state.navigationDrillStep += 1; - advanced = true; - } - if (state.navigationDrillStep >= drill.steps.length) { - v33CompleteNavigationDrill(drill); - } else if (advanced && !quiet) { - render(); - } - } - function v33CompleteNavigationDrill(drill) { - if (!drill || state.navigationDrillCompleted) return; - state.navigationDrillCompleted = true; - const score = v33NavigationScore(); - const result = { drillId:drill.id, title:drill.title, trainee:state.traineeName, score, mistakes:state.navigationDrillMistakes || 0, skips:state.navigationDrillSkips || 0, startedAt:state.navigationDrillStartedAt, completedAt:new Date().toISOString() }; - state.navigationDrillResults.push(result); - state.navigationDrillResults = state.navigationDrillResults.slice(-40); - state.navigationDrillLog.unshift({ type:'complete', drillId:drill.id, label:drill.title, score, time:result.completedAt, trainee:state.traineeName }); - logAction('navigation_drill_complete', `${drill.title} | Score ${score}%`, { tone: score >= 85 ? 'good' : 'warn', message: score >= 85 ? 'Navigation drill passed.' : 'Navigation drill completed with route mistakes. Repeat before live-account work.' }); - toast(`Navigation drill complete. Score ${score}%.`, score >= 85 ? 'good' : 'warn'); - render(); - } - function v33NavigationClickGate(e) { - const drill = v33ActiveNavigationDrill(); - const step = v33CurrentNavigationStep(); - if (!drill || !step) return; - const target = e.target; - if (!target || !target.closest) return; - if (target.closest('[data-action="stopNavDrill"], [data-action="skipNavStep"], [data-action="restartNavDrill"], [data-action="startNavDrill"], [data-view="navDrills"], .toast-stack')) return; - const selectors = [step.selector].concat(step.allowSelectors || []).filter(Boolean); - let correct = false; - for (const sel of selectors) { - try { if (target.closest(sel)) { correct = true; break; } } catch (err) {} - } - if (correct) { setTimeout(() => v33EvaluateNavigationDrill(false), 0); return; } - e.preventDefault(); - e.stopPropagation(); - if (e.stopImmediatePropagation) e.stopImmediatePropagation(); - v33RecordNavigationMiss(step, target); - } - function v33RecordNavigationMiss(step, target) { - const drill = v33ActiveNavigationDrill(); - const clicked = v33DescribeClickedElement(target); - state.navigationDrillMistakes = (state.navigationDrillMistakes || 0) + 1; - state.navigationDrillLog.unshift({ type:'wrong_click', drillId:drill ? drill.id : '', stepId:step.id, label:step.label, clicked, time:new Date().toISOString(), trainee:state.traineeName }); - logAction('navigation_wrong_click', `${step.label} | clicked ${clicked}`, { tone:'bad', message:'Wrong navigation click blocked. Follow the highlighted target.' }); - toast(`Wrong click blocked. Current step: ${step.label}.`, 'bad'); - } - function v33DescribeClickedElement(el) { - if (!el) return 'unknown element'; - const action = el.closest('[data-action]')?.dataset?.action; - const view = el.closest('[data-view]')?.dataset?.view; - const campaign = el.closest('[data-campaign]')?.dataset?.campaign; - const tab = el.closest('[data-tab]')?.dataset?.tab; - const text = String(el.textContent || '').trim().replace(/\s+/g,' ').slice(0, 60); - return action ? `action:${action}` : view ? `view:${view}` : campaign ? `campaign:${campaign}` : tab ? `tab:${tab}` : text || el.tagName || 'unknown element'; - } - let v33LastHighlightedStepKey = ''; - function v33ApplyNavigationDrillHighlight() { - $$('.nav-drill-target').forEach(el => el.classList.remove('nav-drill-target')); - const drill = v33ActiveNavigationDrill(); - const step = v33CurrentNavigationStep(); - if (!drill || !step || !step.selector) return; - let el = null; - try { el = $(step.selector); } catch (err) { el = null; } - if (!el) return; - el.classList.add('nav-drill-target'); - const key = `${drill.id}:${state.navigationDrillStep}`; - if (key !== v33LastHighlightedStepKey) { - v33LastHighlightedStepKey = key; - setTimeout(() => { try { el.scrollIntoView({ block:'center', inline:'nearest' }); } catch (err) {} }, 30); - } - } - - const v33NormalizeStateBase = normalizeState; - normalizeState = function() { v33NormalizeStateBase(); v33EnsureState(); }; - - const v33RenderMainBase = renderMain; - renderMain = function() { - if (state.view === 'navDrills') return renderNavigationDrillsPage(); - return v33RenderMainBase(); - }; - - renderSidebar = function() { - return ` - `; - }; - - const v33RenderRightRailBase = renderRightRail; - renderRightRail = function() { return v33RenderNavigationDrillRail() + v33RenderRightRailBase(); }; - - const v33BindEventsBase = bindEvents; - bindEvents = function() { - v33BindEventsBase(); - const rootEl = $('#root'); - if (rootEl) { - rootEl.removeEventListener('click', v33NavigationClickGate, true); - rootEl.addEventListener('click', v33NavigationClickGate, true); - } - v33ApplyNavigationDrillHighlight(); - }; - - const v33HandleActionBase = handleAction; - handleAction = function(e, action, el) { - if (['startNavDrill','stopNavDrill','skipNavStep','restartNavDrill'].includes(action)) { - e.stopPropagation(); - if (action === 'startNavDrill') v33StartNavigationDrill(el.dataset.id); - if (action === 'stopNavDrill') v33StopNavigationDrill(); - if (action === 'skipNavStep') v33SkipNavigationStep(); - if (action === 'restartNavDrill') { - const active = v33ActiveNavigationDrill(); - if (active) v33StartNavigationDrill(active.id); - } - return; - } - v33HandleActionBase(e, action, el); - }; - - const v33RenderNavigationMapBase = renderNavigationMap; - renderNavigationMap = function() { - return `${pageTitle('Navigation map', 'Teach VAs where to go before they memorize what to do. This page maps the ads console operating routes.', '')} - -

Guided route training

V3.3

Guided drills highlight the exact next click, block wrong clicks, score misses, and write results to the trainer dashboard log.

Wrong-click feedbackTrainer scoringPath memory
`; - }; - - const v33RenderDashboardBase = renderDashboard; - renderDashboard = function() { - const html = v33RenderDashboardBase(); - return html.replace('Navigation drill', 'Navigation drill').replace('Use the left menu: Campaign manager → campaign row → Search terms → action buttons.', 'Use Guided drills for highlighted click paths: Campaign manager → campaign row → Search terms → action buttons.'); - }; - - const v33RenderTrainerDashboardBase = renderTrainerDashboard; - renderTrainerDashboard = function() { - const html = v33RenderTrainerDashboardBase(); - const results = (state.navigationDrillResults || []).slice().reverse().slice(0, 8); - const block = `

Navigation drill results

${results.length}
${results.length ? `
${results.map(r=>``).join('')}
CompletedDrillScoreMistakesSkips
${fmt.date(r.completedAt)}${safe(r.title)}${r.score}%${r.mistakes}${r.skips}
` : '

No guided navigation drill results yet.

'}
`; - return html + block; - }; - - const v33ExportTrainerLogBase = exportTrainerLog; - exportTrainerLog = function() { - const rows = [['Trainee','Time','Action','Detail','Grade','Feedback']].concat(state.actionLog.map(a => [a.trainee || state.traineeName, a.time, a.type, a.detail, a.quality || '', a.feedback || ''])); - const drillRows = [['Trainee','Completed','Drill','Score','Mistakes','Skips']].concat((state.navigationDrillResults || []).map(r => [r.trainee, r.completedAt, r.title, r.score, r.mistakes, r.skips])); - const text = v31Csv(rows) + '\n\nNavigation Drill Results\n' + v31Csv(drillRows); - downloadText('ppc-simulator-trainer-log-v3.3.csv', text, 'text/csv'); - logAction('export_trainer_log', 'Trainer log with navigation drills exported', 'good'); - toast('Trainer log exported with navigation drill results.', 'good'); - }; - - renderDocumentation = function() { - return `${pageTitle('Documentation', 'Living product notes for the Amazon PPC Training Simulator V3.3.', '')} -
-

What this simulator trains

  • SP setup, automatic target groups, manual keyword/product targeting, negatives, harvesting, placements, budgets, and reports.
  • SB setup with Product Collection, Store Spotlight, Video, creative fields, destinations, review states, and targeting.
  • SD setup with contextual targeting, views remarketing, purchases remarketing, creative, products, and audience logic.
  • Campaign manager navigation, guided click paths, report interpretation, bulk operations, Integrity Center, and trainer certification.
-

What changed in V3.3

  • Added Guided drills as a dedicated training module.
  • Added click-by-click route instructions with highlighted targets.
  • Added wrong-click blocking, mistake counts, skip counts, and scoring.
  • Added navigation drill results to the Trainer dashboard and trainer log export.
  • Added guided route coverage for SP search terms, SP placements, SB creative review, SD audience targeting, and report workflows.
  • Kept V3.2 relationship hardening, Integrity Center QA, bulk validation, reports, and scenario generation intact.
-

Recommended training path

  • Day 1: Guided drills, navigation map, glossary, product readiness, and campaign filters.
  • Day 2: SP Auto, Manual Keyword, and Product Targeting builds.
  • Day 3: Search term harvesting, negative exact, negative phrase, and bid reviews.
  • Day 4: SB creative formats, Store destinations, and video requirements.
  • Day 5: SD contextual and remarketing setup, plus why SD is not a search-term workflow.
  • Day 6: Reports, bulk ops, Integrity Center, trainer review, and certification.
-

Known limits

  • No real Amazon connection, Seller Central access, or live bulk upload.
  • No multi-user backend. Progress saves in the local browser.
  • Metrics are simulated for training decisions, not forecasting.
  • The UI is inspired by Amazon Ads Console workflows but is not an exact clone.
-
`; - }; - - exportDocs = function() { - const md = `# Amazon PPC Training Simulator V3.3 Documentation\n\n## Purpose\nTrain virtual assistants on Amazon PPC navigation and operations without Seller Central or Amazon Ads access.\n\n## V3.3 headline addition\nGuided navigation drills now provide click-by-click path training. The simulator highlights the next correct element, blocks wrong clicks, records mistakes, supports skips, calculates a navigation score, and writes results to the Trainer dashboard.\n\n## Guided drill coverage\n1. SP Search terms waste control: Campaign manager -> SP Auto campaign -> Search terms -> Negative exact -> Negative targeting verification.\n2. SP placement controls: Campaign manager -> Manual SP campaign -> Placements -> Save -> Change history.\n3. SB creative review: Creative assets -> SB Video campaign -> Overview -> Targeting.\n4. Reports workflow: Reports -> Request report -> Copy report rows.\n5. SD audience targeting: Campaign manager -> SD Views Remarketing -> Targeting -> Reports.\n\n## Object model\n- Portfolio -> Campaign\n- Campaign -> Ad group\n- Ad group -> Product ad\n- Ad group -> Target\n- Campaign/ad group -> Negative\n- Target -> Search term row when a match exists\n- Campaign -> Creative for SB and SD\n- Campaign -> Budget rule\n- Campaign -> Structured change history\n- Navigation drill -> Ordered step list -> Trainer result log\n\n## V3.3 improvements\n- Dedicated Guided drills page.\n- Active drill card in the right rail.\n- Highlighted current click target.\n- Wrong-click blocking and feedback.\n- Step skip support for trainer-led sessions.\n- Trainer dashboard drill results.\n- Trainer CSV export includes navigation drill results.\n- Documentation updated for the new training path.\n\n## QA checklist\n- Start each guided drill from the Guided drills page.\n- Confirm the highlighted target appears.\n- Click a wrong element and confirm the click is blocked and logged.\n- Complete the drill and confirm the score appears in Trainer dashboard.\n- Run Integrity Center after bulk operations, scenario generation, duplicate campaigns, or imports.\n\n## Safety limit\nThis simulator is a sandbox. It does not connect to Amazon systems, Seller Central, Amazon Ads, or live accounts. Metrics are simulated and intended for training only.\n`; - downloadText('amazon-ppc-simulator-v3.3-docs.md', md, 'text/markdown'); - logAction('export_docs', 'V3.3 documentation exported', 'good'); toast('Documentation exported.', 'good'); - }; - - const v33RenderSettingsBase = renderSettings; - renderSettings = function() { - return `${pageTitle('Simulator settings', 'Reset data, toggle hints, and control training difficulty.', '')} -
-

Training controls

Local browser state


-

Build notes

Version ${APP_VERSION}

This static simulator saves progress in your browser with LocalStorage. V3.3 adds guided navigation drills with target highlighting, wrong-click blocking, scoring, and trainer results, while keeping V3.2 relationship hardening and QA intact.

-
`; - }; - - const v33ResetAllBase = resetAll; - resetAll = function() { - v33ResetAllBase(); - state.activeNavigationDrillId=''; state.navigationDrillStep=0; state.navigationDrillMistakes=0; state.navigationDrillSkips=0; state.navigationDrillLog=[]; state.navigationDrillResults=[]; state.navigationDrillStartedAt=''; state.navigationDrillCompleted=false; - render(); - }; - - v33EnsureState(); - - - if (!state.bulkInput || !state.bulkInput.includes('entity,operation')) state.bulkInput = V31_BULK_TEMPLATE; - - render(); - - \ No newline at end of file diff --git a/legacy/amazon_ppc_simulator_e2e.py b/legacy/amazon_ppc_simulator_e2e.py deleted file mode 100644 index dd20536..0000000 --- a/legacy/amazon_ppc_simulator_e2e.py +++ /dev/null @@ -1,142 +0,0 @@ -import sys -import os -from playwright.sync_api import sync_playwright, expect - -def run_e2e_tests(): - print("==========================================") - print("🚀 STARTING AMAZON PPC SIMULATOR BROWSER E2E TEST") - print("==========================================") - - with sync_playwright() as p: - # Launch headless Chromium browser - browser = p.chromium.launch(headless=True) - page = browser.new_page() - - # Navigate to the offline simulator HTML page - html_path = "file://" + os.path.join(os.path.dirname(os.path.abspath(__file__)), "amazon_ppc_simulator.html") - print(f"🔗 Navigating to: {html_path}") - page.goto(html_path) - - # 1. Verify Initial Simulator Render & State - print("🔍 Step 1: Verifying Initial Simulator Dashboard Render") - page.wait_for_selector(".topbar") - expect(page.locator(".brand")).to_contain_text("Ads Console Training Simulator") - expect(page.locator("[data-action='profileToggleDropdown']")).to_contain_text("Trainee 1") - print("✅ Step 1 Passed: Initial dashboard rendered and Trainee 1 is active by default.") - - # 2. Test Dropdown Profile Switcher Quick-Create - print("🔍 Step 2: Testing Profile Switcher Dropdown Quick-Create") - page.click("[data-action='profileToggleDropdown']") - page.wait_for_selector("#profileDropdownMenu", state="visible") - - # Fill name and click create - page.fill("#newProfileNameInput", "E2E Tester") - page.click("[data-action='profileCreate']") - - # Verify active profile updated and toast appeared - expect(page.locator("[data-action='profileToggleDropdown']")).to_contain_text("E2E Tester") - page.wait_for_selector(".toast.good") - expect(page.locator(".toast.good")).to_contain_text("Created and switched to profile: E2E Tester") - print("✅ Step 2 Passed: Quick-profile creation and switching works with instant UI updates.") - - # 3. Test Trainer Dashboard Profiles Administration Panel - print("🔍 Step 3: Testing Trainer Dashboard Profiles Admin Panel") - # Navigate to trainer dashboard - page.click(".sidebar [data-view='trainer']") - page.wait_for_selector("text=Trainee Profiles Management") - - # Verify the profiles are listed in the table - expect(page.locator(".drill-result-table").first).to_contain_text("E2E Tester") - expect(page.locator(".drill-result-table").first).to_contain_text("Trainee 1") - - # Create a third profile "Trainer Admin" from the admin panel input - page.fill("#dashboardNewProfileInput", "Trainer Admin") - page.click("[data-action='profileDashboardCreate']") - - # Verify active trainee name has updated to Trainer Admin - expect(page.locator("[data-action='profileToggleDropdown']")).to_contain_text("Trainer Admin") - - # Go back to trainer view and verify all three profiles are present - page.click(".sidebar [data-view='trainer']") - page.wait_for_selector("text=Trainer Admin") - expect(page.locator(".drill-result-table").first).to_contain_text("Trainer Admin") - expect(page.locator(".drill-result-table").first).to_contain_text("E2E Tester") - expect(page.locator(".drill-result-table").first).to_contain_text("Trainee 1") - print("✅ Step 3 Passed: Trainer dashboard administration panel renders, registers, and switches profiles properly.") - - # 4. Test State Isolation between Profiles - print("🔍 Step 4: Testing State Isolation between Profiles") - # Change Campaign Manager search or settings in Trainer Admin profile - page.click(".sidebar [data-view='campaigns']") - page.fill("input[placeholder='Search campaigns, portfolio, targeting']", "Special Campaign Search Filter") - - # Switch back to E2E Tester profile using the dashboard panel - page.click(".sidebar [data-view='trainer']") - # Find the switch button specifically for E2E Tester profile and click it - switch_btn = page.locator("tr", has_text="E2E Tester").locator("button[data-action='profileSwitch']") - switch_btn.click() - - # Verify active user is back to E2E Tester - expect(page.locator("[data-action='profileToggleDropdown']")).to_contain_text("E2E Tester") - - # Go to campaigns view and verify the search input was NOT changed (it is clean/empty) - page.click(".sidebar [data-view='campaigns']") - search_val = page.locator("input[placeholder='Search campaigns, portfolio, targeting']").input_value() - assert search_val == "", f"Expected search value to be empty for E2E Tester profile, but got: '{search_val}'" - print("✅ Step 4 Passed: States are perfectly isolated. Changes in one workspace do not corrupt others.") - - # 5. Test Wrong-Click Blocking Bypass for Profile Operations - print("🔍 Step 5: Testing Wrong-Click Blocking Bypass during Guided Drills") - # Start a guided drill - page.click(".sidebar [data-view='navDrills']") - page.wait_for_selector("text=Find and block waste from Search terms") - # Click start drill on first card - page.locator("[data-action='startNavDrill'][data-id='nav-sp-search-term-negative']").click() - page.wait_for_selector(".nav-drill-card") - - # Click on the profile dropdown toggle (which is not the drill step target) - page.click("[data-action='profileToggleDropdown']") - # Verify dropdown opens and NO wrong-click warning toast is displayed - page.wait_for_selector("#profileDropdownMenu", state="visible") - - warning_toast_exists = page.locator(".toast.bad").count() > 0 - assert not warning_toast_exists, "Wrong-click blocking was incorrectly triggered on a profile interaction!" - - # Close the dropdown - page.click("[data-action='profileToggleDropdown']") - - # Stop the drill - page.click("[data-action='stopNavDrill']") - print("✅ Step 5 Passed: Profile operations successfully bypassed the wrong-click drill gate without penalties.") - - # 6. Test Profile Deletion - print("🔍 Step 6: Testing Profile Deletion") - page.click(".sidebar [data-view='trainer']") - page.wait_for_selector("text=Trainee Profiles Management") - - # We have Trainer Admin, E2E Tester, and Trainee 1. - # Let's delete "Trainer Admin" profile - # Since we have custom confirm() handler, we will auto-accept dialogs - page.on("dialog", lambda dialog: dialog.accept()) - - delete_btn = page.locator("tr", has_text="Trainer Admin").locator("button[data-action='profileDelete']") - delete_btn.click() - - # Verify "Trainer Admin" is gone from the table - page.wait_for_timeout(500) # wait briefly for UI update - expect(page.locator(".drill-result-table").first).not_to_contain_text("Trainer Admin") - print("✅ Step 6 Passed: Profiles can be safely deleted and removed from index.") - - browser.close() - - print("==========================================") - print("🎉 ALL AMAZON PPC SIMULATOR BROWSER E2E TESTS PASSED SUCCESSFULLY!") - print("==========================================") - -if __name__ == "__main__": - try: - run_e2e_tests() - sys.exit(0) - except Exception as e: - print(f"❌ TEST RUN FAILED: {e}") - sys.exit(1) diff --git a/legacy/amazon_ppc_simulator_plan.md b/legacy/amazon_ppc_simulator_plan.md deleted file mode 100644 index 45a0ff4..0000000 --- a/legacy/amazon_ppc_simulator_plan.md +++ /dev/null @@ -1,128 +0,0 @@ -# Amazon PPC Training Simulator, Build Plan - -## Goal -Create a safe, realistic simulator for training virtual assistants on Amazon PPC navigation, setup, and management without Seller Central or Amazon Ads console access. - -## Current Build Included -- Single-file offline HTML app -- Console-inspired navigation and layout -- Campaign manager with SP, SB, and SD campaigns -- Campaign creation wizard -- Search term mining and negative targeting actions -- Bid changes, budget changes, budget rules, placement adjustments -- Reports page with CSV export -- Product and creative asset training pages -- Guided missions with scoring -- Navigation map and PPC glossary - -## Ad Types Covered - -### Sponsored Products -Training flows: -- Automatic targeting -- Manual keyword targeting -- Manual product targeting -- Search term harvesting -- Negative exact and negative phrase training -- Bid optimization -- Placement adjustments -- Campaign duplication and status changes - -### Sponsored Brands -Training flows: -- Product collection -- Store spotlight -- Video -- Brand name, logo placeholder, headline, destination checks -- Keyword, product, and category targeting -- Creative QA before launch -- Search term review for SB keyword workflows - -### Sponsored Display -Training flows: -- Contextual targeting -- Views remarketing -- Purchases remarketing -- Auto-generated creative -- Custom image creative -- Product and audience selection -- Budget and bid setup -- Display-style reporting without keyword dependency - -## Training Missions -1. SP Search Term Harvest and Negation -2. Build Sponsored Products Campaign -3. Build Sponsored Brands Product Collection -4. Build Sponsored Display Remarketing -5. Budget and Placement Controls - -## Recommended Next Features - -### Phase 2, Training Depth -- Add user accounts with trainee roles -- Save progress locally or to a backend -- Add trainer review mode -- Add randomized account scenarios -- Add mistake penalties and final certification scores -- Add SOP-linked hints per screen - -### Phase 3, Realistic Operations -- Bulk operations simulator -- Portfolio budget simulator -- Dayparting and budget pacing simulator -- Placement performance report by campaign -- Product readiness alerts, inventory, buy box, pricing, coupon state -- Search query performance style dashboard -- Change log export for trainer review - -### Phase 4, Team Enablement -- Admin panel for creating custom missions -- Trainee leaderboard -- VA onboarding curriculum mode -- Scenario packs by skill level -- Agency/client account templates -- Before-and-after optimization grading - -## Suggested VA Curriculum - -### Week 1, Navigation -- Understand campaign manager layout -- Identify SP, SB, SD differences -- Open campaign detail pages -- Read core metrics -- Export a report - -### Week 2, Setup -- Build SP automatic campaign -- Build SP manual exact campaign -- Build SB product collection campaign -- Build SB video campaign -- Build SD remarketing campaign - -### Week 3, Management -- Add negatives -- Harvest search terms -- Adjust bids -- Adjust budgets -- Create budget rules -- Update placements -- Read change history - -### Week 4, Certification -- Complete all guided missions -- Explain each action before clicking -- Submit report interpretation -- Pass setup QA checklist -- Pass optimization QA checklist - -## QA Checklist for Trainers -- Trainee names the ad type before making changes -- Trainee checks campaign status before editing -- Trainee checks date range before reading performance -- Trainee reads spend, sales, orders, ACOS, CPC, CVR -- Trainee explains why a target gets increased, decreased, paused, harvested, or negated -- Trainee uses exact negatives for precise waste and phrase negatives only when safe -- Trainee validates SB creative fields before launch -- Trainee understands SD audience/contextual targeting is not keyword targeting -- Trainee checks change history after major edits - diff --git a/legacy/amazon_ppc_simulator_v3_3_changelog.md b/legacy/amazon_ppc_simulator_v3_3_changelog.md deleted file mode 100644 index 900f442..0000000 --- a/legacy/amazon_ppc_simulator_v3_3_changelog.md +++ /dev/null @@ -1,89 +0,0 @@ -# Amazon PPC Training Simulator V3.3 Changelog - -## Version - -3.3 - -## Release date - -2026-06-25 - -## Headline - -Added guided click-by-click navigation drills with target highlighting, wrong-click blocking, scoring, and trainer results. - -## Added - -- Guided drills sidebar item -- Guided navigation drills page -- Five route-based drills: - - SP search-term waste control - - SP placement controls - - SB creative review path - - Report request and copy workflow - - SD audience targeting path -- Active drill card in the right rail -- Current-step instruction panel -- Coach hint per navigation step -- Highlighted current click target -- Wrong-click blocking during active drills -- Mistake count -- Skip count -- Completion score -- Navigation drill result log -- Navigation drill results in Trainer dashboard -- Trainer log export section for navigation drill results -- V3.3 in-app documentation -- V3.3 external documentation -- V3.3 QA script and QA results JSON - -## Changed - -- Updated app title to V3.3 -- Updated APP_VERSION to 3.3 -- Updated simulator settings copy -- Updated Navigation map with Guided drills CTA -- Updated dashboard navigation alert copy -- Updated Trainer dashboard output -- Updated documentation export filename and content -- Updated trainer log export filename and content - -## Preserved from V3.2 - -- Relationship-safe campaign object model -- Integrity Center checks and self-heal -- Entity-based bulk operations -- Report request queue -- Randomized scenario generator -- Trainer notes -- Progress export and import -- Action grading -- Structured history -- SP, SB, and SD coverage - -## Fixed during V3.3 build - -- Fixed trainer log export reference so it uses the existing CSV utility instead of an undefined helper. -- Prevented duplicate V3.3 layer insertion in the final app file. -- Confirmed the final file contains exactly one V3.3 guided drill layer. - -## QA commands run - -```bash -node --check amazon_ppc_simulator_check.js -node amazon_ppc_simulator_v3_3_qa.js -``` - -## QA result - -Status: passed -Pass count: 18 -Failure count: 0 - -## Remaining improvement candidates - -- Add a true browser automation test with click simulation through Chromium or Playwright. -- Add custom trainer-authored drill creation inside the UI. -- Add drill categories by trainee level. -- Add a certification page that combines missions, guided drills, and trainer checklist scores. -- Add keyboard navigation support for accessibility drills. diff --git a/legacy/amazon_ppc_simulator_v3_3_qa_report.md b/legacy/amazon_ppc_simulator_v3_3_qa_report.md deleted file mode 100644 index 7cd76cf..0000000 --- a/legacy/amazon_ppc_simulator_v3_3_qa_report.md +++ /dev/null @@ -1,80 +0,0 @@ -# Amazon PPC Training Simulator V3.3 QA Report - -## Version tested - -3.3 - -## Test date - -2026-06-25 - -## Files tested - -- amazon_ppc_simulator.html -- amazon_ppc_simulator_check.js -- amazon_ppc_simulator_v3_3_qa.js - -## QA status - -Passed - -## Summary - -The V3.3 build passed syntax, static, VM render, guided drill, completion path, skip path, and export execution checks. - -## Commands run - -```bash -node --check amazon_ppc_simulator_check.js -node amazon_ppc_simulator_v3_3_qa.js -``` - -## QA result JSON - -```json -{ - "status": "passed", - "version": "3.3", - "passCount": 18, - "failureCount": 0 -} -``` - -## Checks passed - -1. Version is 3.3 -2. V3.3 layer appears exactly once -3. Sidebar has Guided drills -4. Navigation drill result state exists -5. Wrong-click documentation exists -6. Drill definitions exist -7. Initial render works -8. Initial navigation render includes Guided drills -9. Guided drills page renders -10. SP negative drill card renders -11. SD audience drill card renders -12. Active drill rail renders -13. First drill step renders -14. SP drill completes through the expected operations -15. SP drill scores 100 percent on a clean path -16. Skip path completes a drill -17. Skip count appears after skipped drill -18. Trainer log and documentation export functions run - -## Manual review notes - -The V3.3 implementation uses a final hardening layer inserted after the V3.2 final layer. This matches the app’s existing override architecture and avoids risky rewrites of the full single-file app. - -The implementation adds new UI and state without changing the campaign relationship model. Existing campaign, ad group, target, negative, search term, creative, report, bulk, and trainer objects remain intact. - -## Limitations of QA - -The QA uses a Node VM with DOM stubs. It validates syntax, function execution, rendered HTML strings, and guided drill logic. It does not replace a full browser visual matrix. - -Recommended future QA: - -- Chromium or Playwright click simulation -- Mobile viewport visual check -- LocalStorage import and export roundtrip test -- Drill wrong-click simulation with real DOM events -- Accessibility keyboard navigation test diff --git a/legacy/amazon_ppc_simulator_v3_3_qa_results.json b/legacy/amazon_ppc_simulator_v3_3_qa_results.json deleted file mode 100644 index 4215a6d..0000000 --- a/legacy/amazon_ppc_simulator_v3_3_qa_results.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "status": "passed", - "version": "3.4", - "passCount": 23, - "failureCount": 0, - "checks": [ - { - "name": "version is 3.4", - "status": "passed" - }, - { - "name": "single V3.3 layer", - "status": "passed" - }, - { - "name": "sidebar has Guided drills", - "status": "passed" - }, - { - "name": "drill result state exists", - "status": "passed" - }, - { - "name": "wrong-click documentation present", - "status": "passed" - }, - { - "name": "drill definitions present", - "status": "passed" - }, - { - "name": "initial render works", - "status": "passed" - }, - { - "name": "initial nav render includes Guided drills", - "status": "passed" - }, - { - "name": "guided drills page renders", - "status": "passed" - }, - { - "name": "SP negative drill card renders", - "status": "passed" - }, - { - "name": "SD audience drill card renders", - "status": "passed" - }, - { - "name": "active drill rail renders", - "status": "passed" - }, - { - "name": "first drill step renders", - "status": "passed" - }, - { - "name": "SP drill completes through operations", - "status": "passed" - }, - { - "name": "SP drill scoring works", - "status": "passed" - }, - { - "name": "skip path completes drill", - "status": "passed" - }, - { - "name": "skip count shown", - "status": "passed" - }, - { - "name": "profile section in topbar", - "status": "passed" - }, - { - "name": "profiles management section on trainer dashboard", - "status": "passed" - }, - { - "name": "can create and switch to a new user profile", - "status": "passed" - }, - { - "name": "user profiles maintain separate, isolated workspaces", - "status": "passed" - }, - { - "name": "can safely delete user profiles", - "status": "passed" - }, - { - "name": "trainer log and docs export functions run", - "status": "passed" - } - ], - "qaScope": [ - "static version and module checks", - "JavaScript VM render smoke test", - "Guided drills page render", - "Active drill rail render", - "SP negative drill completion path", - "Skip/completion path", - "Trainer log and docs export execution" - ] -} \ No newline at end of file diff --git a/legacy/amazon_ppc_simulator_v3_3_release_manifest.json b/legacy/amazon_ppc_simulator_v3_3_release_manifest.json deleted file mode 100644 index 6200acf..0000000 --- a/legacy/amazon_ppc_simulator_v3_3_release_manifest.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "Amazon PPC Training Simulator", - "version": "3.3", - "releaseDate": "2026-06-25", - "primaryFile": "amazon_ppc_simulator.html", - "newModules": [ - "Guided drills", - "Target highlighting", - "Wrong-click blocking", - "Navigation scoring", - "Trainer drill results" - ], - "qa": { - "status": "passed", - "passCount": 18, - "failureCount": 0, - "commands": [ - "node --check amazon_ppc_simulator_check.js", - "node amazon_ppc_simulator_v3_3_qa.js" - ] - }, - "limitations": [ - "No live Amazon connection", - "No Seller Central connection", - "No multi-user backend", - "No full browser visual matrix in this QA pass" - ] -} diff --git a/legacy/amazon_ppc_simulator_v3_4_changelog.md b/legacy/amazon_ppc_simulator_v3_4_changelog.md deleted file mode 100644 index a4b09b6..0000000 --- a/legacy/amazon_ppc_simulator_v3_4_changelog.md +++ /dev/null @@ -1,36 +0,0 @@ -# Amazon PPC Training Simulator V3.4 Changelog - -## Version - -3.4 - -## Release date - -2026-07-14 - -## Headline - -Added Multi-User Trainee Profiles, Workspace State Isolation, Header Profile Dropdown Selector, and Trainer Dashboard Administration panel with Playwright Browser E2E automation testing. - -## Added - -- Multi-User Profile system with transparent backward-compatibility migration. -- Header Profile Dropdown Selector featuring a list of trainees, quick profile creator, and direct link to the Trainer Dashboard. -- Profiles Administration Panel on the Trainer Dashboard showing profile creation dates, active states, status, and administration controls (Switch, Rename, Delete). -- Playwright E2E browser automation test suite in Python (`amazon_ppc_simulator_e2e.py`). -- Automatic close-on-click-outside and click gate bypasses for user profile interactions during active navigation drills. -- Assertions in static QA checks and browser E2E tests validating profile isolation, switching, creation, and deletion. - -## Changed - -- Updated app title to V3.4 Sandbox. -- Updated documentation block inside the app and settings build version indicators to cover V3.4 additions. -- Hardened static QA check script with relative paths for local development runs. - -## Preserved from V3.3 - -- 5 Guided navigation drills with highlighted targets and mistaken-click penalties. -- Integrity Center self-heal checks and object-map export. -- Bulk operations template downloads and CSV parsing logic. -- Scenario generation modes (Beginner, Intermediate, Advanced). -- Core SP, SB, and SD relationship-safe campaign architectures. diff --git a/legacy/amazon_ppc_simulator_v3_4_documentation.md b/legacy/amazon_ppc_simulator_v3_4_documentation.md deleted file mode 100644 index 1929c96..0000000 --- a/legacy/amazon_ppc_simulator_v3_4_documentation.md +++ /dev/null @@ -1,87 +0,0 @@ -# Amazon PPC Training Simulator V3.4 Documentation - -## Purpose - -The simulator trains virtual assistants on Amazon PPC navigation, campaign setup, campaign management, reporting, bulk operations, and safe optimization decisions without giving access to Seller Central or Amazon Ads. - -V3.4 introduces **Multi-User Trainee Profiles & Workspace State Isolation**. The simulator now allows trainers and trainees to maintain multiple completely isolated student accounts in the same browser, with their own campaigns, action logs, metrics, training missions, and guided drill scores. It also adds a comprehensive browser End-to-End (E2E) automation test suite using Playwright. - -## Release summary - -Version: 3.4 -Release date: 2026-07-14 -Build type: Static single-file HTML app -Storage: Browser LocalStorage (Multi-profile keys) -Live account access: None -External dependencies: None - -## Major V3.4 additions - -### 1. Multi-User Trainee Profiles -Trainees can now create unique profiles to manage separate practice workspaces. -* **Workspace Isolation:** Each profile holds its own unique campaigns, ad groups, budget rules, history logs, completed drills, action logs, feedback logs, etc. -* **Trainee Dropdown Switcher:** A dropdown switcher is embedded directly into the header bar. Trainees can instantly switch accounts, quick-create a profile, or delete other profiles. -* **Dropdown open/close:** Supported by dropdown state and click-outside automatic dismissal. - -### 2. Trainer Profiles Administration Dashboard -The Trainer Dashboard now includes a dedicated **Trainee Profiles Management** panel. -* Displays a summary table of all offline profiles. -* Shows profile metadata: creation date, last active timestamp, and current status. -* Provides full administrator actions to **Switch**, **Rename**, or **Delete** individual profiles. - -### 3. Drill Bypass Integration -* Interacting with the profile switcher or creating profiles is excluded from the wrong-click gate during active guided drills. Trainees can adjust their user settings or switch profiles mid-drill without triggering mistake counters or score penalties. - -### 4. Playwright Browser E2E Test Suite -* Added `amazon_ppc_simulator_e2e.py`—a true browser E2E test suite that runs a headless Chromium browser using Playwright. -* Automatically verifies initial dashboard renders, profile creation, header switcher behavior, state isolation, drill bypass, and deletion safety. - -## Guided drill coverage - -### Drill 1: SP search-term waste control -Route: Campaign manager -> SP Auto campaign -> Search terms -> Negative exact -> Negative targeting verification. - -### Drill 2: SP placement controls -Route: Campaign manager -> Manual SP campaign -> Placements -> Save -> Change history. - -### Drill 3: SB creative review path -Route: Creative assets -> SB Video campaign -> Overview -> Targeting. - -### Drill 4: Report request and copy workflow -Route: Reports -> Request report -> Copy report rows. - -### Drill 5: SD audience targeting path -Route: Campaign manager -> SD Views Remarketing -> Targeting -> Reports. - -## State storage schema in V3.4 - -* `amazonPpcSimulator.profilesIndex`: Array of `{ id, name, createdAt, lastActiveAt }`. -* `amazonPpcSimulator.activeProfileId`: String ID of the active profile (e.g. `p-default`). -* `amazonPpcSimulator.profile.`: Individual JSON string representing the full state model of that profile. - -## Existing V3.3 systems retained - -V3.4 retains all legacy validation and simulation work: -* Relationship-safe campaign model -* Campaign-to-adgroup-to-target constraints -* Target to search term mapping -* Budget rules, placements, and creative approval states -* Integrity Center self-heal checks -* Entity-based bulk operations -* Reports requested queue - -## QA commands run - -```bash -# Core static VM validation tests (23 checks) -node amazon_ppc_simulator_v3_3_qa.js - -# Real browser E2E test suite (6 steps) -python amazon_ppc_simulator_e2e.py -``` - -## Known limits - -* No live Amazon Ads or Seller Central connection. -* Static single-file page utilizing browser LocalStorage. -* Metrics are simulated for training, not forecasting. diff --git a/legacy/amazon_ppc_simulator_v3_4_object_model.md b/legacy/amazon_ppc_simulator_v3_4_object_model.md deleted file mode 100644 index 0035365..0000000 --- a/legacy/amazon_ppc_simulator_v3_4_object_model.md +++ /dev/null @@ -1,271 +0,0 @@ -# Amazon PPC Training Simulator V3.4 Object Model - -## Core advertising hierarchy - -```text -Portfolio - -> Campaign - -> Ad group - -> Product ad - -> Target - -> Search term row, when applicable - -> Negative targeting row - -> Budget rule - -> Creative, for SB and SD - -> Structured history row -``` - -## Trainee Profile Model - -Primary fields: - -- id (e.g. `p-default`, `P-XXXXXX`) -- name (Trainee's name) -- createdAt (ISO DateTime string) -- lastActiveAt (ISO DateTime string) - -Rules: -- Profiles are indexed globally under the key `amazonPpcSimulator.profilesIndex`. -- The active profile is referenced by `amazonPpcSimulator.activeProfileId`. -- Each profile has its own complete independent copy of the simulator's global state, stored under the key `amazonPpcSimulator.profile.`. -- Deleting a profile deletes its associated state and metadata, and if it was active, switches to another profile. -- There must always be at least one trainee profile. - -## Campaign - -Primary fields: - -- id -- type: SP, SB, or SD -- name -- portfolio -- status -- dailyBudget -- startDate -- endDate -- targetingMode -- adFormat -- bidStrategy -- defaultBid -- products -- placements -- metrics - -Child collections: - -- adGroups -- ads -- targets -- searchTerms -- negatives -- budgetRules -- history - -SB and SD campaigns also use: - -- creative -- creativeStatus -- creativeIssue - -## Ad group - -Primary fields: - -- id -- campaignId -- name -- status -- defaultBid - -Rules: - -- Every campaign must have at least one ad group. -- Every ad group must point to its parent campaign. -- Archived campaigns cascade archive status to ad groups. - -## Product ad - -Primary fields: - -- id -- campaignId -- adGroupId -- asin -- status -- name - -Rules: - -- Every product ad must point to a valid campaign. -- Every product ad must point to a valid ad group under the same campaign. -- Every product ad ASIN must exist in the mock product catalog. - -## Target - -Primary fields: - -- id -- campaignId -- adGroupId -- type -- value -- match -- bid -- status -- metrics - -Target types include: - -- Auto -- Keyword -- ASIN -- Category -- Audience - -Rules: - -- Every target must point to a valid campaign. -- Every target must point to a valid ad group under the same campaign. -- Every target must have a bid greater than zero. -- SP automatic campaigns should include Close match, Loose match, Substitutes, and Complements. - -## Search term row - -Primary fields: - -- id -- campaignId -- adGroupId -- targetId -- term -- target -- recommendation -- metrics - -Rules: - -- Search term rows belong to SP and SB workflows. -- SD campaigns should not use search term rows. -- Search term rows should link to a target when a match exists. - -## Negative targeting row - -Primary fields: - -- id -- campaignId -- adGroupId -- type -- value -- sourceSearchTermId - -Rules: - -- Negative rows must point to a valid campaign. -- Negative rows must point to a valid ad group. -- Duplicate negative type plus value combinations are flagged. - -## Budget rule - -Primary fields: - -- id -- campaignId -- name -- type -- increase -- condition -- status - -Rules: - -- Budget rules must point to a valid campaign. -- Training-safe increase range is 1 percent to 200 percent. - -## Creative object - -Used by SB and SD campaigns. - -Primary fields: - -- headline -- brandName -- logo -- destination -- video -- image - -Rules: - -- SB Product Collection needs at least three products. -- SB Store Spotlight should use Brand Store destination. -- SB Video needs a video placeholder. -- Rejected SB or SD creative blocks safe enablement. - -## V3.4 navigation drill model - -```text -NavigationDrill - -> NavigationStep - -> Expected selector - -> Completion predicate - -> Coach hint - -> NavigationDrillResult - -> Trainee - -> Score - * Mistakes - * Skips - * StartedAt - * CompletedAt -``` - -## NavigationDrill - -Primary fields: - -- id -- type -- title -- difficulty -- minutes -- summary -- route -- steps - -## NavigationStep - -Primary fields: - -- id -- label -- instruction -- selector -- allowSelectors -- done predicate -- coach - -Rules: - -- Only the current step selector is treated as the correct click. -- Optional allowSelectors support safe inputs needed before the main click. -- Wrong clicks are blocked and recorded. -- Skips are allowed but penalized. - -## NavigationDrillResult - -Primary fields: - -- drillId -- title -- trainee -- score -- mistakes -- skips -- startedAt -- completedAt - -Rules: - -- Results are stored in the active profile's LocalStorage state. -- Results appear in Trainer dashboard. -- Results export with the trainer log CSV. diff --git a/legacy/amazon_ppc_simulator_v3_4_qa.js b/legacy/amazon_ppc_simulator_v3_4_qa.js deleted file mode 100644 index 79e1d35..0000000 --- a/legacy/amazon_ppc_simulator_v3_4_qa.js +++ /dev/null @@ -1,156 +0,0 @@ -const fs = require('fs'); -const vm = require('vm'); -const path = require('path'); -const htmlPath = path.join(__dirname, 'amazon_ppc_simulator.html'); -const html = fs.readFileSync(htmlPath, 'utf8'); -const scriptMatch = html.match(/ + + + + +
+
+ + + Campaign ManagerPPC Teaching Simulator +
+ +
+ + + +
DS
Demo SellerAcme Storefront
+
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+ + + + +
+ + LIVE + + Space play S skip C create + +
+ +
+ + +
+ + + + diff --git a/public/dashboard-preview.png b/public/dashboard-preview.png deleted file mode 100644 index 5800bd5..0000000 Binary files a/public/dashboard-preview.png and /dev/null differ diff --git a/public/dashboard-preview.webp b/public/dashboard-preview.webp deleted file mode 100644 index 2580a40..0000000 Binary files a/public/dashboard-preview.webp and /dev/null differ diff --git a/public/file.svg b/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/hero_bg.webp b/public/hero_bg.webp deleted file mode 100644 index c30e255..0000000 Binary files a/public/hero_bg.webp and /dev/null differ diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/window.svg b/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/migrate_tables.py b/scripts/migrate_tables.py deleted file mode 100644 index c4aa096..0000000 --- a/scripts/migrate_tables.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -""" -Phase 5 migration: replace `
...
` -with `...
` (children mode) in all AdConsole files. - -Idempotent: if the file is already migrated, no change is made. -""" -import os -import re -import sys - -ROOT = r"C:\Users\Agent\Documents\Amazon-ad-console\src\components\AdConsole" - -# Files to migrate (the .tsx files that use .table-wrap) -TARGETS = [ - r"details\OverviewTab.tsx", - r"details\NegativesTab.tsx", - r"details\BudgetRulesTab.tsx", - r"details\AdGroupsTab.tsx", - r"details\TargetsTab.tsx", - r"details\SearchTermsTab.tsx", - r"details\ManagerCampaignsTab.tsx", - r"details\ManagerAdGroupsTab.tsx", - r"details\ManagerTargetsTab.tsx", - r"details\ManagerSearchTermsTab.tsx", - r"details\ManagerNegativesTab.tsx", - r"features\bulk\BulkOpsPage.tsx", - r"features\reports\ReportsPage.tsx", - r"features\drills\DrillsPage.tsx", - r"features\trainer\TrainerPage.tsx", - r"PortfolioOverview.tsx", - r"Dashboard.tsx", - r"wizard\steps\sb\Step3ProductsCreative.tsx", -] - - -def migrate(path: str) -> bool: - with open(path, "r", encoding="utf-8") as f: - text = f.read() - - original = text - - # 1. Add Table import after the first `@astryxdesign/core/...` import line, - # or as a fresh import. We append an "astryxdesign/core/Table" import. - if "@astryxdesign/core/Table" not in text: - # find the first @astryxdesign/core/... import and add Table import - # right after it. - m = re.search(r"^import .* from '@astryxdesign/core/[A-Za-z]+';$", - text, re.MULTILINE) - if m: - last = m - # find the last consecutive astryx import - for m2 in re.finditer( - r"^import .* from '@astryxdesign/core/[A-Za-z]+';$", - text, re.MULTILINE, - ): - # check if the next line is also an astryx import - end = m2.end() - next_line_start = end - # see if next 200 chars contain another astryx import - next_chunk = text[end:end + 200] - if re.match(r"^import .* from '@astryxdesign/core/[A-Za-z]+';", - next_chunk): - last = m2 - else: - break - insertion = last.end() - text = ( - text[:insertion] - + "\nimport { Table } from '@astryxdesign/core/Table';" - + text[insertion:] - ) - else: - # no existing astryx import — add after the first Card or Button - # import (whichever is first). Fall back to the very top. - m = re.search( - r"^import .* from '@astryxdesign/core/[A-Za-z]+';$", - text, re.MULTILINE, - ) - if m: - insertion = m.end() - text = ( - text[:insertion] - + "\nimport { Table } from '@astryxdesign/core/Table';" - + text[insertion:] - ) - else: - # add after 'use client'; - m = re.search(r"^'use client';\n", text, re.MULTILINE) - if m: - insertion = m.end() - text = ( - text[:insertion] - + "\nimport { Table } from '@astryxdesign/core/Table';" - + text[insertion:] - ) - else: - print(f"WARN: no anchor for {path}; skipping import", file=sys.stderr) - return False - - # 2. Replace `
` followed by `` with `
`. - # The pattern in our code is always: - #
- #
- # (with any whitespace). We match `
` and - # `
` and emit `
`. - new_text, n1 = re.subn( - r'
\s*
', - "
", - text, - ) - text = new_text - - # 3. Replace `
\s*
` (close) with ``. - new_text, n2 = re.subn(r'\s*
', "", text) - text = new_text - - # 4. Sanity: ensure we actually changed something if the file had - # `table-wrap` originally. - if "table-wrap" in original and (n1 == 0 or n2 == 0): - print(f"ERROR: {path} has table-wrap but migration didn't match " - f"(n1={n1}, n2={n2})", file=sys.stderr) - return False - - if text == original: - return False # nothing to do - - with open(path, "w", encoding="utf-8") as f: - f.write(text) - return True - - -def main(): - changed = [] - for rel in TARGETS: - full = os.path.join(ROOT, rel) - if not os.path.exists(full): - print(f"MISS: {full}", file=sys.stderr) - continue - if migrate(full): - changed.append(rel) - - print(f"Changed {len(changed)} file(s):") - for c in changed: - print(f" {c}") - - -if __name__ == "__main__": - main() diff --git a/skills/loop-budget/SKILL.md b/skills/loop-budget/SKILL.md deleted file mode 100644 index f01c678..0000000 --- a/skills/loop-budget/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: loop-budget -description: Check token budget and run-log spend before and after a loop run. Enforces early exit when over budget or when there is no actionable work. ---- - -# Loop Budget Guard - -Run at the **start** and **end** of every loop iteration. - -## Start of run - -1. Read `loop-budget.md` for daily caps and kill-switch flags. -2. Read recent entries in `loop-run-log.md` (last 24h). -3. Sum `tokens_estimate` for the active pattern today. -4. If spend ≥ 80% of the pattern's daily cap → **report-only mode** (no sub-agents, no auto-fix). -5. If spend ≥ 100% or `loop-pause-all` is set → **exit immediately** with a one-line note in STATE.md. -6. If watchlist/state has no actionable items → **exit in <5k tokens** (do not spawn sub-agents). - -## End of run - -Append one JSON object to `loop-run-log.md`: - -```json -{ - "run_id": "", - "pattern": "", - "duration_s": , - "items_found": , - "actions_taken": , - "escalations": , - "tokens_estimate": , - "outcome": "no-op | report-only | fix-proposed | escalated" -} -``` - -## Rules - -- Never exceed `max sub-agent spawns/run` from `loop-budget.md`. -- High-cadence patterns (CI Sweeper, PR Babysitter) **must** early-exit when nothing is actionable. -- On self-throttle, append a line to `loop-budget.md` under **Alerts This Period**. \ No newline at end of file diff --git a/skills/loop-constraints/SKILL.md b/skills/loop-constraints/SKILL.md deleted file mode 100644 index 2f921cb..0000000 --- a/skills/loop-constraints/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: loop-constraints -description: > - Read loop-constraints.md at the start of every run and enforce every rule. - This skill runs BEFORE triage or any action skill. Constraints are binding. -user_invocable: true ---- - -# Loop Constraints Enforcer - -You are the guardrail. Before any other work begins, you MUST: - -1. Read `loop-constraints.md` from the project root. -2. Load every rule into your working memory. -3. Check if `loop-pause-all` is active → exit immediately. -4. Apply these rules to EVERY action that follows. - -## How to enforce - -- Before pushing: re-read the Push & Merge section. If ANY rule blocks it, stop and tell the human. -- Before editing a file: re-read the Paths section. If the path matches a denylist pattern, escalate. -- Before proposing a fix: re-read the Code section. Run tests. One fix per run. -- Before merging: re-read the Push & Merge section. Human must approve. - -## Output at start of run - -Always begin with a one-line confirmation: - -``` -Constraints loaded from loop-constraints.md: N rules active. -``` - -If no `loop-constraints.md` exists, say so and proceed with default safety rules from `docs/safety.md`. - -## Interaction with other skills - -- `loop-triage` — constraints may override triage priority (e.g. "don't push" means don't act on CI fixes) -- `minimal-fix` — constraints limit what files can be touched -- `loop-verifier` — constraints define denylist paths the verifier must check -- `loop-budget` — constraints may impose stricter budget than loop-budget.md - -## Default constraints (when no file exists) - -If `loop-constraints.md` is absent, enforce these minimums: -- Never edit `.env`, `.env.*`, `auth/`, `payments/`, `secrets/`, `credentials/` -- Never auto-merge to main -- Never disable tests -- Escalate after 3 failed fix attempts diff --git a/skills/loop-triage/SKILL.md b/skills/loop-triage/SKILL.md deleted file mode 100644 index 65b4a0c..0000000 --- a/skills/loop-triage/SKILL.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: loop-triage -description: > - Triage recent changes, CI failures, issues, and conversations. - Produces a concise, actionable findings report suitable for a loop to consume. - Writes structured output to a state file or Linear board. -user_invocable: true ---- - -# Loop Triage Skill - -You are an expert engineering triage agent. Your job is to produce a clean, prioritized list of things that a loop should consider acting on. - -## Inputs (the loop will provide these) -- Recent CI / test failures (last 24h) -- Open issues / Linear tickets assigned to the team -- Recent commits on main (last 24–48h) -- Any Slack / chat threads the loop has visibility into -- The current state file (what the loop already knows about) - -## Output Format - -Produce a markdown report with these sections: - -### 1. High-Priority Items (act on these) -- Clear, one-line description -- Why it matters (impact, risk, or customer pain) -- Suggested next action for the loop (e.g. "draft minimal fix in isolated worktree") -- Rough effort estimate - -### 2. Watch Items (monitor, do not act yet) -- Same format but lower urgency - -### 3. Noise / Ignore -- Brief list of things the loop looked at and decided were not worth action - -### 4. State Updates -- Any facts the loop should remember for the next run (e.g. "PR #1234 now has 2 approvals") - -## Rules - -- Be brutally concise. The loop (and the human reading the state) will thank you. -- Only put something in "High-Priority" if a reasonable engineer would want to know about it today. -- When in doubt, put it in Watch or Noise rather than creating work. -- Never propose architectural overhauls during triage — this skill is for signal, not invention. -- Respect the project's existing skills and conventions (they will be provided in context). - -## Example Invocation (in a Grok loop) - -``` -/loop 30m Call $loop-triage and append the high-priority items to STATE.md. For any high-priority item that looks like a small bugfix, open a worktree and spawn a minimal-fix sub-agent. -``` - -The triage skill should be the "eyes" of the loop. Keep it focused and honest. diff --git a/skills/loop-verifier/SKILL.md b/skills/loop-verifier/SKILL.md deleted file mode 100644 index 935d3bb..0000000 --- a/skills/loop-verifier/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: loop-verifier -description: > - Independent verification agent for loop-produced changes. Finds reasons to - reject. Runs tests. Confirms diff scope. Use after minimal-fix or any - implementer sub-agent — never in the same role as the implementer. -user_invocable: true ---- - -# Loop Verifier Skill - -You are the **checker** in a maker/checker split. Your job is to **reject** unless evidence is strong. - -## Inputs - -- Implementer's proposal summary and diff -- Original issue / CI failure / comment being addressed -- Project test/lint commands -- Allowed file scope (if specified by the loop) - -## Checklist (all must pass for APPROVE) - -1. **Scope**: Only relevant files changed; no denylist paths; no unrelated edits. -2. **Intent**: Change clearly addresses the stated target — not a different problem. -3. **Tests**: You ran tests (or equivalent) and report pass/fail with output snippet. -4. **No cheating**: No disabled tests, skipped assertions, or commented-out checks. -5. **Risk**: For medium+ risk, recommend human review even if tests pass. - -## Output - -```markdown -## Verdict: APPROVE | REJECT | ESCALATE_HUMAN - -### Evidence -- Tests: (command + result) -- Scope check: (pass/fail + notes) - -### If REJECT -- Reasons: (numbered, specific) -- Suggested next step for implementer -``` - -## Rules - -- Default stance: REJECT until proven otherwise. -- Do not trust implementer's claim that tests passed — run them. -- If you cannot run tests (env issue) → ESCALATE_HUMAN. -- Be concise. The loop and human read this under time pressure. \ No newline at end of file diff --git a/skills/minimal-fix/SKILL.md b/skills/minimal-fix/SKILL.md deleted file mode 100644 index 7b01553..0000000 --- a/skills/minimal-fix/SKILL.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: minimal-fix -description: > - Produce the smallest possible code change that fixes a specific, well-scoped - issue (CI failure, reviewer comment, typo). Use only when the fix target is - explicit. Never refactor unrelated code. -user_invocable: true ---- - -# Minimal Fix Skill - -You fix **one specific problem** with the **smallest diff** that could work. - -## Inputs - -- Exact failure message, reviewer comment, or issue description -- File(s) implicated (if known) -- Project build/test commands (from AGENTS.md or project skills) -- Path denylist (from loop safety policy — never edit `.env`, `auth/`, `payments/`, secrets) - -## Process - -1. Reproduce or confirm the failure locally if possible. -2. Identify the minimal root cause — not symptoms in distant files. -3. Change only what is required. No drive-by refactors. -4. Run tests/lint relevant to the change. -5. Summarize: what changed, why, what you ran. - -## Output - -```markdown -## Minimal Fix Proposal -- Target: (issue/comment/failure) -- Files changed: (list) -- Diff summary: (1-3 bullets) -- Tests run: (commands + result) -- Risk: low | medium — if medium, recommend human review -``` - -## Rules - -- If fix requires >5 files or design change → stop and escalate. -- If path is on denylist → stop and escalate. -- Do not disable tests or weaken assertions to go green. -- Do not mark yourself "done" — verifier decides. \ No newline at end of file diff --git a/src/app/__tests__/amazon-theme.test.ts b/src/app/__tests__/amazon-theme.test.ts deleted file mode 100644 index 1cb3ddf..0000000 --- a/src/app/__tests__/amazon-theme.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Amazon platform theme regression test. - * - * Pins the canonical Amazon platform color tokens so a future design - * tweak can't quietly drift away from the official palette. Values are - * the published Amazon retail + Amazon Ads design-system hex codes: - * - * - Amazon retail top-nav: #131921 - * - Amazon retail page bg: #eaeded - * - Amazon orange (brand): #ff9900 - * - Amazon orange hover: #e47911 - * - Amazon orange active: #c45500 - * - Amazon clickable teal: #007185 (also used for focus) - * - Amazon error red: #cc0c39 - * - Amazon success green: #067d62 - * - * Refs: - * - https://www.colorfetch.com/palette/amazon.com - * - https://colorswall.com/palette/146377 (aws-technical palette) - * - https://developer.amazon.com/en-US/alexa/alexa-haus/visual-design/apl-style-guide/color - */ -import { describe, it, expect } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -let css = ''; -try { - css = fs.readFileSync( - path.resolve(__dirname, '../../app/globals.css'), - 'utf8', - ); -} catch { - // Source not available — tests will fail, which is the right signal. -} - -function token(name: string): string | undefined { - // Match: --name: #hex; (whitespace tolerant) - const m = css.match(new RegExp(`--${name}\\s*:\\s*(#[0-9a-fA-F]{3,8})`)); - return m?.[1]?.toLowerCase(); -} - -describe('Amazon platform theme — canonical color tokens', () => { - it('--surface-0 is the Amazon retail page background', () => { - expect(token('surface-0')).toBe('#eaeded'); - }); - - it('--surface-3 is the Amazon retail top-nav dark navy', () => { - expect(token('surface-3')).toBe('#131921'); - }); - - it('--surface-4 is the Amazon deepest marketing/hero surface', () => { - expect(token('surface-4')).toBe('#0f1111'); - }); - - it('--accent is Amazon Orange (canonical #ff9900)', () => { - expect(token('accent')).toBe('#ff9900'); - }); - - it('--accent-hover is the canonical Amazon hover orange', () => { - expect(token('accent-hover')).toBe('#e47911'); - }); - - it('--accent-active is the canonical Amazon active orange', () => { - expect(token('accent-active')).toBe('#c45500'); - }); - - it('--border-focus is Amazon clickable teal #007185 (used for focus + links)', () => { - expect(token('border-focus')).toBe('#007185'); - }); - - it('--info matches the Amazon clickable teal', () => { - expect(token('info')).toBe('#007185'); - }); - - it('--danger is the Amazon error red #cc0c39', () => { - expect(token('danger')).toBe('#cc0c39'); - }); - - it('--success is the Amazon success teal #067d62', () => { - expect(token('success')).toBe('#067d62'); - }); - - it('--nav-bg follows the top-nav surface', () => { - expect(token('nav-bg')).toBe(token('surface-3')); - }); -}); diff --git a/src/app/__tests__/astryx-theme.test.ts b/src/app/__tests__/astryx-theme.test.ts deleted file mode 100644 index 4e20265..0000000 --- a/src/app/__tests__/astryx-theme.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Phase 1 — Astryx ↔ Amazon theme bridge contract. - * - * Pins the integration contract between the Astryx design system and the - * existing Amazon platform theme. The bridge file (src/app/astryx-theme.css) - * must: - * - * 1. Exist at the documented path. - * 2. Map the Astryx design tokens (--color-*, --font-family-*, --spacing-*, - * --radius-*, --shadow-*) onto the existing Amazon tokens defined in - * globals.css (--surface-*, --ink-*, --accent, --success, --danger, - * --warning, --info, --font-display, --font-mono, --space-*, --radius-*, - * --shadow-*). - * 3. Be imported from globals.css so it actually applies. - * 4. Not change any of the canonical Amazon token values (visual identity - * is preserved — this is a pure bridge, not a re-skin). - * - * Why this matters: the previous Astryx migration (PRs #32-#36) regressed - * the visual theme in 3 places because the bridge file didn't exist — Astryx - * components rendered with their own default neutral palette, conflicting - * with the Amazon colors. This test prevents that regression. - */ -import { describe, it, expect, beforeAll } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -// `__dirname` here resolves to src/app/__tests__ at runtime. -// The existing amazon-theme.test.ts uses the same convention. -const GLOBALS_CSS = path.resolve(__dirname, '../../app/globals.css'); -const BRIDGE_CSS = path.resolve(__dirname, '../astryx-theme.css'); - -let globals = ''; -let bridge = ''; - -beforeAll(() => { - globals = fs.readFileSync(GLOBALS_CSS, 'utf8'); - if (fs.existsSync(BRIDGE_CSS)) { - bridge = fs.readFileSync(BRIDGE_CSS, 'utf8'); - } -}); - -function bridgeDecl(name: string): string | undefined { - // Capture the RHS of a `--name: ;` declaration inside the bridge. - // Tolerates whitespace and the var(--…) form, which is what we want to assert. - const m = bridge.match(new RegExp(`--${name}\\s*:\\s*([^;]+);`)); - return m?.[1]?.trim(); -} - -function globalsDecl(name: string): string | undefined { - const m = globals.match(new RegExp(`--${name}\\s*:\\s*([^;]+);`)); - return m?.[1]?.trim(); -} - -function readCss(): string { - // Phase 3 Card overrides live in the bridge CSS (unlayered → wins cascade). - return bridge; -} - -describe('Phase 1 — astryx-theme bridge exists', () => { - it('the bridge file is at src/app/astryx-theme.css', () => { - expect(fs.existsSync(BRIDGE_CSS), `expected bridge at ${BRIDGE_CSS}`).toBe(true); - }); - - it('globals.css imports the bridge AFTER the Astryx theme files (cascade wins)', () => { - // CSS @import must appear before any rules, so the bridge cannot sit - // after the :root block. What matters for the cascade is that the - // bridge is the LAST @import — its tokens win over theme-neutral - // defaults that come earlier in the import order. - const bridgeIdx = globals.indexOf("@import './astryx-theme.css'"); - const themeNeutralIdx = globals.indexOf("@import '@astryxdesign/theme-neutral/theme.css'"); - const astryxCoreIdx = globals.indexOf("@import '@astryxdesign/core/astryx.css'"); - expect(bridgeIdx, 'globals.css must @import the bridge').toBeGreaterThan(-1); - expect(themeNeutralIdx, 'globals.css must @import theme-neutral').toBeGreaterThan(-1); - expect(bridgeIdx, 'bridge must be imported after theme-neutral to win cascade').toBeGreaterThan(themeNeutralIdx); - expect(bridgeIdx, 'bridge must be imported after core astryx to win cascade').toBeGreaterThan(astryxCoreIdx); - }); - - it('bridge targets [data-astryx-theme] (Astryx theme wrapper)', () => { - // Astryx Theme component sets data-astryx-theme on its children wrapper. - // The bridge must target that wrapper so its overrides take precedence - // over the neutralTheme defaults that @scope in theme.css sets on the - // same element. - expect(bridge).toMatch(/\[data-astryx-theme[^\]]*\]/); - }); -}); - -describe('Phase 1 — astryx-theme bridge maps colors to Amazon tokens', () => { - it('--color-background-surface → var(--surface-1)', () => { - expect(bridgeDecl('color-background-surface')).toBe('var(--surface-1)'); - }); - - it('--color-background-body → var(--surface-0)', () => { - expect(bridgeDecl('color-background-body')).toBe('var(--surface-0)'); - }); - - it('--color-background-card → var(--surface-1)', () => { - expect(bridgeDecl('color-background-card')).toBe('var(--surface-1)'); - }); - - it('--color-background-popover → var(--surface-1)', () => { - expect(bridgeDecl('color-background-popover')).toBe('var(--surface-1)'); - }); - - it('--color-background-muted → var(--surface-2)', () => { - expect(bridgeDecl('color-background-muted')).toBe('var(--surface-2)'); - }); - - it('--color-text-primary → var(--ink-900)', () => { - expect(bridgeDecl('color-text-primary')).toBe('var(--ink-900)'); - }); - - it('--color-text-secondary → var(--ink-500)', () => { - expect(bridgeDecl('color-text-secondary')).toBe('var(--ink-500)'); - }); - - it('--color-text-disabled → var(--ink-400)', () => { - expect(bridgeDecl('color-text-disabled')).toBe('var(--ink-400)'); - }); - - it('--color-accent → var(--accent)', () => { - expect(bridgeDecl('color-accent')).toBe('var(--accent)'); - }); - - it('--color-border → var(--border)', () => { - expect(bridgeDecl('color-border')).toBe('var(--border)'); - }); - - it('--color-success → var(--success)', () => { - expect(bridgeDecl('color-success')).toBe('var(--success)'); - }); - - it('--color-error → var(--danger) (Amazon error red, not Astryx red)', () => { - expect(bridgeDecl('color-error')).toBe('var(--danger)'); - }); - - it('--color-warning → var(--warning)', () => { - expect(bridgeDecl('color-warning')).toBe('var(--warning)'); - }); -}); - -describe('Phase 3 — astryx-theme bridge adds Card radius override', () => { - it('.astryx-card gets Amazon border-radius (12px = --radius-lg)', () => { - const css = readCss(); - // Card base StyleX class uses 8px; we bump to 12px to match the old .card. - expect(css).toMatch(/\.astryx-card\s*\{[^}]*border-radius:\s*var\(--radius-lg\)[^}]*\}/); - }); - - it('.astryx-card[data-variant="muted"] uses Amazon --surface-2 background', () => { - const css = readCss(); - expect(css).toMatch(/\.astryx-card\[data-variant="muted"\]\s*\{[^}]*background:\s*var\(--surface-2\)[^}]*\}/); - }); -}); - -describe('Phase 1 — astryx-theme bridge maps type + spacing + radius + shadow', () => { - it('--font-family-body → var(--font-body) (Geist)', () => { - expect(bridgeDecl('font-family-body')).toBe('var(--font-body)'); - }); - - it('--font-family-heading → var(--font-display) (Geist)', () => { - expect(bridgeDecl('font-family-heading')).toBe('var(--font-display)'); - }); - - it('--font-family-code → var(--font-mono) (Geist Mono)', () => { - expect(bridgeDecl('font-family-code')).toBe('var(--font-mono)'); - }); - - it('--spacing-2 maps to a 4px-base scale (var(--space-2) = 8px)', () => { - expect(bridgeDecl('spacing-2')).toBe('var(--space-2)'); - }); - - it('--spacing-4 maps to a 4px-base scale (var(--space-4) = 16px)', () => { - expect(bridgeDecl('spacing-4')).toBe('var(--space-4)'); - }); - - it('--radius-element → var(--radius-md)', () => { - expect(bridgeDecl('radius-element')).toBe('var(--radius-md)'); - }); - - it('--radius-container → var(--radius-lg)', () => { - expect(bridgeDecl('radius-container')).toBe('var(--radius-lg)'); - }); - - it('--shadow-low → var(--shadow-sm) (subtle Amazon elevation)', () => { - expect(bridgeDecl('shadow-low')).toBe('var(--shadow-sm)'); - }); -}); - -describe('Phase 1 — bridge does not change Amazon token values (no visual regression)', () => { - // These are the canonical Amazon palette tokens. The bridge must only - // reference them via var(--…), not redefine them. If a re-skin happens, - // these values would change and visual identity would drift. - const CANONICAL = [ - 'surface-0', 'surface-1', 'surface-2', 'surface-3', 'surface-4', - 'ink-900', 'ink-800', 'ink-700', 'ink-500', 'ink-400', 'ink-300', 'ink-200', - 'accent', 'accent-hover', 'accent-active', - 'success', 'warning', 'danger', 'info', - 'border', 'border-focus', - ] as const; - - for (const name of CANONICAL) { - it(`--${name} value in globals.css is unchanged (Amazon palette)`, () => { - const v = globalsDecl(name); - // The bridge must not redefine it (would mean visual identity drift). - expect(bridgeDecl(name), `bridge must not redefine --${name}`).toBeUndefined(); - // The original definition still exists in globals.css. - expect(v, `globals.css must still define --${name}`).toBeDefined(); - }); - } -}); diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 5ef28c1..0000000 --- a/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { handlers } from '@/lib/auth'; - -export const { GET, POST } = handlers; diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts deleted file mode 100644 index 00f41fa..0000000 --- a/src/app/api/auth/register/__tests__/route.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Tests for /api/auth/register input validation. - * - * The route previously accepted any non-empty password (even 1 character) - * and any string containing '@' as an "email", with no format check. - */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const { prismaMock, bcryptMock } = vi.hoisted(() => ({ - prismaMock: { - user: { - findFirst: vi.fn(), - create: vi.fn(), - }, - }, - bcryptMock: { - hash: vi.fn(), - }, -})); - -vi.mock('@/lib/prisma', () => ({ - prisma: prismaMock, -})); - -vi.mock('bcryptjs', () => ({ - default: bcryptMock, -})); - -import { POST } from '../route'; -import { Prisma } from '@/generated/prisma/client'; - -function makeRequest(body: unknown): Request { - return new Request('http://localhost/api/auth/register', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - prismaMock.user.findFirst.mockResolvedValue(null); - prismaMock.user.create.mockResolvedValue({ id: 'user-1' }); - bcryptMock.hash.mockResolvedValue('hashed'); -}); - -describe('POST /api/auth/register', () => { - it('rejects a password shorter than 8 characters', async () => { - const res = await POST(makeRequest({ email: 'a@b.com', password: '1' })); - expect(res.status).toBe(400); - expect(prismaMock.user.create).not.toHaveBeenCalled(); - }); - - it('rejects a malformed email', async () => { - const res = await POST(makeRequest({ email: 'not-an-email', password: 'longenough' })); - expect(res.status).toBe(400); - expect(prismaMock.user.create).not.toHaveBeenCalled(); - }); - - it('accepts a valid email and an 8+ character password', async () => { - const res = await POST(makeRequest({ email: 'a@b.com', password: 'longenough' })); - expect(res.status).toBe(201); - expect(prismaMock.user.create).toHaveBeenCalledTimes(1); - }); - - it('normalizes email casing/whitespace before checking for an existing user', async () => { - await POST(makeRequest({ email: ' Foo@Example.COM ', password: 'longenough' })); - expect(prismaMock.user.findFirst).toHaveBeenCalledWith({ - where: { email: { equals: 'foo@example.com', mode: 'insensitive' } }, - }); - }); - - it('stores the normalized email, not the raw input casing', async () => { - await POST(makeRequest({ email: 'Foo@Example.COM', password: 'longenough' })); - const [args] = prismaMock.user.create.mock.calls[0] as [{ data: { email: string } }]; - expect(args.data.email).toBe('foo@example.com'); - }); - - it('treats a case-variant of an existing email as a duplicate', async () => { - prismaMock.user.findFirst.mockResolvedValueOnce({ id: 'existing-user' }); - const res = await POST(makeRequest({ email: 'FOO@example.com', password: 'longenough' })); - expect(res.status).toBe(400); - expect(prismaMock.user.create).not.toHaveBeenCalled(); - }); - - it('treats a concurrent duplicate registration (unique constraint race) as "already exists", not a 500', async () => { - // findFirst didn't see it yet, but create() hits the DB's unique - // constraint because another request won the race in between. - prismaMock.user.create.mockRejectedValueOnce( - new Prisma.PrismaClientKnownRequestError('Unique constraint failed on the fields: (`email`)', { - code: 'P2002', - clientVersion: 'test', - }), - ); - const res = await POST(makeRequest({ email: 'a@b.com', password: 'longenough' })); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toMatch(/already exists/i); - }); - - it('still returns 500 for a create() failure that is not a unique constraint violation', async () => { - prismaMock.user.create.mockRejectedValueOnce(new Error('connection reset')); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const res = await POST(makeRequest({ email: 'a@b.com', password: 'longenough' })); - expect(res.status).toBe(500); - consoleSpy.mockRestore(); - }); -}); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts deleted file mode 100644 index 5ecceef..0000000 --- a/src/app/api/auth/register/route.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { NextResponse } from 'next/server'; -import bcrypt from 'bcryptjs'; -import { prisma } from '@/lib/prisma'; -import { normalizeEmail } from '@/lib/email'; -import { Prisma } from '@/generated/prisma/client'; - -export async function POST(request: Request) { - try { - const { email, password, name } = await request.json(); - - if (!email || !password) { - return NextResponse.json( - { error: 'Email and password are required' }, - { status: 400 } - ); - } - - if (typeof email !== 'string') { - return NextResponse.json( - { error: 'Enter a valid email address' }, - { status: 400 } - ); - } - - const normalizedEmail = normalizeEmail(email); - - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) { - return NextResponse.json( - { error: 'Enter a valid email address' }, - { status: 400 } - ); - } - - if (typeof password !== 'string' || password.length < 8) { - return NextResponse.json( - { error: 'Password must be at least 8 characters' }, - { status: 400 } - ); - } - - // Case-insensitive check so a legacy mixed-case row (from before email - // normalization) still counts as a duplicate — the DB's unique - // constraint is case-sensitive and wouldn't catch it on its own. - const existingUser = await prisma.user.findFirst({ - where: { email: { equals: normalizedEmail, mode: 'insensitive' } }, - }); - - if (existingUser) { - return NextResponse.json( - { error: 'User already exists' }, - { status: 400 } - ); - } - - const passwordHash = await bcrypt.hash(password, 10); - - // The findFirst check above and this create() aren't atomic, so a - // concurrent registration with the same normalized email can still - // slip past it and hit the DB's unique constraint here. Treat that - // race the same as the check finding it first, rather than letting it - // fall through to the generic 500 below. - try { - const user = await prisma.user.create({ - data: { - email: normalizedEmail, - name: name || normalizedEmail.split('@')[0], - passwordHash, - }, - }); - - return NextResponse.json( - { message: 'User created', userId: user.id }, - { status: 201 } - ); - } catch (createError) { - if ( - createError instanceof Prisma.PrismaClientKnownRequestError && - createError.code === 'P2002' - ) { - return NextResponse.json( - { error: 'User already exists' }, - { status: 400 } - ); - } - throw createError; - } - } catch (error) { - console.error('Registration error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} diff --git a/src/app/api/campaigns/[id]/__tests__/route.test.ts b/src/app/api/campaigns/[id]/__tests__/route.test.ts deleted file mode 100644 index aaf124f..0000000 --- a/src/app/api/campaigns/[id]/__tests__/route.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Tests for /api/campaigns/[id] ownership scoping. - * - * PUT and DELETE verified ownership via a separate `findFirst({ id, userId })` - * check, but the mutating `update`/`delete` calls used `where: { id }` alone — - * a defense-in-depth gap where any future refactor that separates the check - * from the mutation could let one user modify another user's campaign by id. - * The fix scopes the mutation itself by `userId` too. - */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const { prismaMock, authMock } = vi.hoisted(() => { - const prismaMock = { - campaign: { - findFirst: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - }, - }; - const authMock = vi.fn(); - return { prismaMock, authMock }; -}); - -vi.mock('@/lib/prisma', () => ({ - prisma: prismaMock, -})); - -vi.mock('@/lib/auth', () => ({ - auth: authMock, -})); - -import { PUT, DELETE } from '../route'; - -const USER = { user: { id: 'user-1' } }; -const params = Promise.resolve({ id: 'campaign-1' }); - -function putRequest(body: unknown): Request { - return new Request('http://localhost/api/campaigns/campaign-1', { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - authMock.mockResolvedValue(USER); - prismaMock.campaign.findFirst.mockResolvedValue({ id: 'campaign-1', userId: 'user-1' }); - prismaMock.campaign.update.mockResolvedValue({ id: 'campaign-1' }); - prismaMock.campaign.delete.mockResolvedValue({ id: 'campaign-1' }); -}); - -describe('PUT /api/campaigns/[id]', () => { - it('scopes the update to the authenticated user, not just the ownership check', async () => { - await PUT(putRequest({ name: 'Renamed' }), { params }); - expect(prismaMock.campaign.update).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: 'campaign-1', userId: 'user-1' } }), - ); - }); - - it('rejects unauthenticated requests with 401 and never touches the DB', async () => { - authMock.mockResolvedValueOnce(null); - const res = await PUT(putRequest({ name: 'x' }), { params }); - expect(res.status).toBe(401); - expect(prismaMock.campaign.update).not.toHaveBeenCalled(); - }); - - it('returns 404 when the campaign is not owned by the caller', async () => { - prismaMock.campaign.findFirst.mockResolvedValueOnce(null); - const res = await PUT(putRequest({ name: 'x' }), { params }); - expect(res.status).toBe(404); - expect(prismaMock.campaign.update).not.toHaveBeenCalled(); - }); -}); - -describe('DELETE /api/campaigns/[id]', () => { - it('scopes the delete to the authenticated user, not just the ownership check', async () => { - await DELETE(new Request('http://localhost/api/campaigns/campaign-1', { method: 'DELETE' }), { params }); - expect(prismaMock.campaign.delete).toHaveBeenCalledWith({ where: { id: 'campaign-1', userId: 'user-1' } }); - }); - - it('returns 404 when the campaign is not owned by the caller', async () => { - prismaMock.campaign.findFirst.mockResolvedValueOnce(null); - const res = await DELETE(new Request('http://localhost/api/campaigns/campaign-1', { method: 'DELETE' }), { params }); - expect(res.status).toBe(404); - expect(prismaMock.campaign.delete).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/campaigns/[id]/route.ts b/src/app/api/campaigns/[id]/route.ts deleted file mode 100644 index 203758e..0000000 --- a/src/app/api/campaigns/[id]/route.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '@/lib/prisma'; -import { auth } from '@/lib/auth'; -import { safeJsonParse } from '@/lib/json'; - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const campaign = await prisma.campaign.findFirst({ - where: { - id, - userId: session.user.id, - }, - }); - - if (!campaign) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } - - // Parse JSON fields — a corrupted value falls back to an empty default - // instead of throwing and 500ing the request. - const parsed = { - ...campaign, - placements: safeJsonParse(campaign.placements, null), - products: safeJsonParse(campaign.products, []), - creative: safeJsonParse(campaign.creative, null), - metrics: safeJsonParse(campaign.metrics, null), - adGroups: safeJsonParse(campaign.adGroups, []), - targets: safeJsonParse(campaign.targets, []), - searchTerms: safeJsonParse(campaign.searchTerms, []), - negatives: safeJsonParse(campaign.negatives, []), - budgetRules: safeJsonParse(campaign.budgetRules, []), - history: safeJsonParse(campaign.history, []), - }; - - return NextResponse.json(parsed); -} - -export async function PUT( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const data = await request.json(); - - // Verify ownership - const existing = await prisma.campaign.findFirst({ - where: { - id, - userId: session.user.id, - }, - }); - - if (!existing) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } - - // Update JSON fields - const updateData: Record = {}; - if (data.placements !== undefined) updateData.placements = JSON.stringify(data.placements); - if (data.products !== undefined) updateData.products = JSON.stringify(data.products); - if (data.creative !== undefined) updateData.creative = JSON.stringify(data.creative); - if (data.metrics !== undefined) updateData.metrics = JSON.stringify(data.metrics); - if (data.adGroups !== undefined) updateData.adGroups = JSON.stringify(data.adGroups); - if (data.targets !== undefined) updateData.targets = JSON.stringify(data.targets); - if (data.searchTerms !== undefined) updateData.searchTerms = JSON.stringify(data.searchTerms); - if (data.negatives !== undefined) updateData.negatives = JSON.stringify(data.negatives); - if (data.budgetRules !== undefined) updateData.budgetRules = JSON.stringify(data.budgetRules); - if (data.history !== undefined) updateData.history = JSON.stringify(data.history); - - // Update scalar fields - const scalarFields = ['type', 'name', 'portfolio', 'status', 'dailyBudget', 'defaultBid', - 'startDate', 'endDate', 'targetingMode', 'adFormat', 'campaignGoal', 'bidStrategy']; - for (const field of scalarFields) { - if (data[field] !== undefined) updateData[field] = data[field]; - } - - const campaign = await prisma.campaign.update({ - where: { id, userId: session.user.id }, - data: updateData, - }); - - return NextResponse.json(campaign); -} - -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - // Verify ownership - const existing = await prisma.campaign.findFirst({ - where: { - id, - userId: session.user.id, - }, - }); - - if (!existing) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } - - await prisma.campaign.delete({ - where: { id, userId: session.user.id }, - }); - - return NextResponse.json({ message: 'Deleted' }); -} diff --git a/src/app/api/campaigns/route.ts b/src/app/api/campaigns/route.ts deleted file mode 100644 index 1a267a8..0000000 --- a/src/app/api/campaigns/route.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '@/lib/prisma'; -import { auth } from '@/lib/auth'; -import { safeJsonParse } from '@/lib/json'; - -export async function GET() { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const campaigns = await prisma.campaign.findMany({ - where: { userId: session.user.id }, - orderBy: { createdAt: 'desc' }, - }); - - // Parse JSON fields — a corrupted value in one row falls back to an - // empty default instead of throwing and 500ing the entire list. - const parsed = campaigns.map((c: any) => ({ - ...c, - placements: safeJsonParse(c.placements, null), - products: safeJsonParse(c.products, []), - creative: safeJsonParse(c.creative, null), - metrics: safeJsonParse(c.metrics, null), - adGroups: safeJsonParse(c.adGroups, []), - targets: safeJsonParse(c.targets, []), - searchTerms: safeJsonParse(c.searchTerms, []), - negatives: safeJsonParse(c.negatives, []), - budgetRules: safeJsonParse(c.budgetRules, []), - history: safeJsonParse(c.history, []), - })); - - return NextResponse.json(parsed); -} - -export async function POST(request: Request) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const data = await request.json(); - const { campaignId, ...rest } = data; - - const campaign = await prisma.campaign.create({ - data: { - userId: session.user.id, - campaignId, - type: rest.type || 'SP', - name: rest.name || 'Untitled Campaign', - portfolio: rest.portfolio, - status: rest.status || 'Enabled', - dailyBudget: rest.dailyBudget || 25, - defaultBid: rest.defaultBid || 0.75, - startDate: rest.startDate, - endDate: rest.endDate, - targetingMode: rest.targetingMode, - adFormat: rest.adFormat, - campaignGoal: rest.campaignGoal, - bidStrategy: rest.bidStrategy, - placements: rest.placements ? JSON.stringify(rest.placements) : null, - products: rest.products ? JSON.stringify(rest.products) : null, - creative: rest.creative ? JSON.stringify(rest.creative) : null, - metrics: rest.metrics ? JSON.stringify(rest.metrics) : null, - adGroups: rest.adGroups ? JSON.stringify(rest.adGroups) : null, - targets: rest.targets ? JSON.stringify(rest.targets) : null, - searchTerms: rest.searchTerms ? JSON.stringify(rest.searchTerms) : null, - negatives: rest.negatives ? JSON.stringify(rest.negatives) : null, - budgetRules: rest.budgetRules ? JSON.stringify(rest.budgetRules) : null, - history: rest.history ? JSON.stringify(rest.history) : null, - }, - }); - - return NextResponse.json(campaign, { status: 201 }); -} diff --git a/src/app/api/sync/__tests__/route.test.ts b/src/app/api/sync/__tests__/route.test.ts deleted file mode 100644 index c5b3912..0000000 --- a/src/app/api/sync/__tests__/route.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Tests for /api/sync POST atomicity (audit B-03). - * - * The original implementation did `deleteMany` then `createMany` outside a - * transaction, so a single bad record or transient DB error left the user - * with an empty cloud account while their previous snapshot was already - * gone. The fix wraps both operations in `prisma.$transaction(async tx => …)` - * and returns 500 with the previous data preserved on any failure. - */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// ---- Mocks (must be hoisted before the route import) ---- - -const { prismaMock, txMock, authMock } = vi.hoisted(() => { - // Prisma's interactive transaction callback receives a transaction client - // shaped like the top-level client, so the route calls `tx.campaign.deleteMany` - // and `tx.campaign.createMany` — NOT a flat `tx.deleteMany`. - const txMock = { - campaign: { - deleteMany: vi.fn(), - createMany: vi.fn(), - }, - }; - const prismaMock = { - $transaction: vi.fn(async (fn: (tx: typeof txMock) => Promise) => fn(txMock)), - campaign: { - findMany: vi.fn(), - }, - }; - const authMock = vi.fn(); - return { prismaMock, txMock, authMock }; -}); - -vi.mock('@/lib/prisma', () => ({ - prisma: prismaMock, -})); - -vi.mock('@/lib/auth', () => ({ - auth: authMock, -})); - -// Import after mocks are in place. -import { POST, GET } from '../route'; - -const ORIGINAL_USER = { user: { id: 'user-1' } }; - -function makeRequest(body: unknown): Request { - return new Request('http://localhost/api/sync', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: typeof body === 'string' ? body : JSON.stringify(body), - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - // clearAllMocks wipes mock implementations, so restore the transaction - // shim and per-method defaults that the route relies on. Per-test - // overrides via `mockResolvedValueOnce` / `mockRejectedValueOnce` come - // AFTER this and take precedence for the first matching call. - prismaMock.$transaction.mockImplementation( - async (fn: (tx: typeof txMock) => Promise) => fn(txMock), - ); - authMock.mockResolvedValue(ORIGINAL_USER); - txMock.campaign.deleteMany.mockResolvedValue({ count: 0 }); - txMock.campaign.createMany.mockResolvedValue({ count: 0 }); -}); - -describe('POST /api/sync', () => { - it('rejects unauthenticated requests with 401', async () => { - authMock.mockResolvedValueOnce(null); - const res = await POST(makeRequest({ campaigns: [] })); - expect(res.status).toBe(401); - expect(prismaMock.$transaction).not.toHaveBeenCalled(); - }); - - it('rejects non-JSON bodies with 400', async () => { - const res = await POST(makeRequest('not json')); - expect(res.status).toBe(400); - expect(prismaMock.$transaction).not.toHaveBeenCalled(); - }); - - it('rejects payloads where `campaigns` is not an array', async () => { - const res = await POST(makeRequest({ campaigns: { id: 'x' } })); - expect(res.status).toBe(400); - expect(prismaMock.$transaction).not.toHaveBeenCalled(); - }); - - it('rejects rows missing a string id before entering the transaction', async () => { - const res = await POST(makeRequest({ campaigns: [{ name: 'no id here' }] })); - expect(res.status).toBe(400); - expect(prismaMock.$transaction).not.toHaveBeenCalled(); - }); - - it('rejects non-object rows before entering the transaction', async () => { - const res = await POST(makeRequest({ campaigns: ['not-an-object', null] })); - expect(res.status).toBe(400); - expect(prismaMock.$transaction).not.toHaveBeenCalled(); - }); - - it('writes all rows inside a single transaction (atomicity contract)', async () => { - txMock.campaign.createMany.mockResolvedValueOnce({ count: 2 }); - const res = await POST( - makeRequest({ - campaigns: [ - { id: 'c1', name: 'Alpha' }, - { id: 'c2', name: 'Bravo' }, - ], - }), - ); - - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ synced: 2 }); - - // Both delete and createMany MUST be called through the interactive - // transaction callback, not directly on the top-level client. This is - // the audit's B-03 fix. - expect(prismaMock.$transaction).toHaveBeenCalledTimes(1); - expect(typeof prismaMock.$transaction.mock.calls[0][0]).toBe('function'); - expect(txMock.campaign.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-1' } }); - expect(txMock.campaign.createMany).toHaveBeenCalledTimes(1); - }); - - it('scopes the delete to the authenticated user (does not touch other users)', async () => { - authMock.mockResolvedValueOnce({ user: { id: 'user-42' } }); - await POST(makeRequest({ campaigns: [] })); - expect(txMock.campaign.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-42' } }); - }); - - it('treats an empty payload as a valid clear-cloud action (still atomic)', async () => { - const res = await POST(makeRequest({ campaigns: [] })); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ synced: 0 }); - expect(txMock.campaign.deleteMany).toHaveBeenCalled(); - expect(txMock.campaign.createMany).not.toHaveBeenCalled(); - }); - - it('returns 500 and reports "previous cloud data preserved" on transaction failure', async () => { - // Simulate the audit scenario: deleteMany succeeds, createMany blows up - // mid-batch. With $transaction the DB should roll back, but the route - // must still surface a 500 to the client and promise preservation. - txMock.campaign.deleteMany.mockResolvedValueOnce({ count: 3 }); - txMock.campaign.createMany.mockRejectedValueOnce(new Error('connection reset')); - prismaMock.$transaction.mockImplementationOnce(async (fn) => fn(txMock)); - - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const res = await POST( - makeRequest({ campaigns: [{ id: 'c1' }, { id: 'c2' }] }), - ); - expect(res.status).toBe(500); - const body = await res.json(); - expect(body.error).toMatch(/preserved/i); - expect(consoleSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); - }); - - it('serializes JSON-typed fields with JSON.stringify, not raw objects', async () => { - txMock.campaign.createMany.mockResolvedValueOnce({ count: 1 }); - const placements = { top: 50, product: 25, rest: 25 }; - const metrics = { impressions: 100, clicks: 5, spend: 1.25, sales: 0, orders: 0 }; - await POST( - makeRequest({ - campaigns: [ - { - id: 'c1', - name: 'Serialization Check', - placements, - metrics, - }, - ], - }), - ); - - const [args] = txMock.campaign.createMany.mock.calls[0] as [{ data: Array> }]; - expect(args.data[0].placements).toBe(JSON.stringify(placements)); - expect(args.data[0].metrics).toBe(JSON.stringify(metrics)); - }); -}); - -describe('GET /api/sync', () => { - it('returns parsed campaigns for the authenticated user', async () => { - prismaMock.campaign.findMany.mockResolvedValueOnce([ - { - campaignId: 'c1', - type: 'SP', - name: 'Alpha', - portfolio: null, - status: 'Enabled', - dailyBudget: 25, - defaultBid: 0.75, - startDate: null, - endDate: null, - targetingMode: null, - adFormat: null, - campaignGoal: null, - bidStrategy: null, - placements: JSON.stringify({ top: 0, product: 0, rest: 0 }), - products: null, - creative: null, - metrics: null, - adGroups: null, - targets: null, - searchTerms: null, - negatives: null, - budgetRules: null, - history: null, - }, - ]); - const res = await GET(); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body).toHaveLength(1); - expect(body[0]).toMatchObject({ id: 'c1', name: 'Alpha' }); - expect(body[0].placements).toEqual({ top: 0, product: 0, rest: 0 }); - }); - - it('rejects unauthenticated GET with 401', async () => { - authMock.mockResolvedValueOnce(null); - const res = await GET(); - expect(res.status).toBe(401); - }); - - it('falls back to empty defaults for a row with corrupted JSON instead of 500ing the whole list', async () => { - prismaMock.campaign.findMany.mockResolvedValueOnce([ - { - campaignId: 'corrupted', - type: 'SP', - name: 'Corrupted Row', - portfolio: null, - status: 'Enabled', - dailyBudget: 25, - defaultBid: 0.75, - startDate: null, - endDate: null, - targetingMode: null, - adFormat: null, - campaignGoal: null, - bidStrategy: null, - placements: '{not valid json', - products: null, - creative: null, - metrics: null, - adGroups: '{also not valid', - targets: null, - searchTerms: null, - negatives: null, - budgetRules: null, - history: null, - }, - { - campaignId: 'healthy', - type: 'SP', - name: 'Healthy Row', - portfolio: null, - status: 'Enabled', - dailyBudget: 25, - defaultBid: 0.75, - startDate: null, - endDate: null, - targetingMode: null, - adFormat: null, - campaignGoal: null, - bidStrategy: null, - placements: null, - products: null, - creative: null, - metrics: null, - adGroups: null, - targets: null, - searchTerms: null, - negatives: null, - budgetRules: null, - history: null, - }, - ]); - const res = await GET(); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body).toHaveLength(2); - expect(body[0].placements).toEqual({ top: 0, product: 0, rest: 0 }); - expect(body[0].adGroups).toEqual([]); - expect(body[1].id).toBe('healthy'); - }); -}); diff --git a/src/app/api/sync/route.ts b/src/app/api/sync/route.ts deleted file mode 100644 index 1184822..0000000 --- a/src/app/api/sync/route.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { NextResponse } from 'next/server'; -import type { Prisma } from '@/generated/prisma/client'; -import { prisma } from '@/lib/prisma'; -import { auth } from '@/lib/auth'; -import { safeJsonParse } from '@/lib/json'; - -/** - * Wire shape of a campaign as the browser sends it in the sync payload. - * Defined locally so this file typechecks before `prisma generate` has run - * (the generated client is gitignored). The Prisma layer is the source of - * truth for what's actually persisted. - */ -type WireCampaign = { id?: unknown } & Record; - -// POST /api/sync - Sync all campaigns from local state to database -// -// Atomicity contract: either every campaign in `campaigns` is written and the -// user's previous cloud snapshot is fully replaced, or no write happens at all. -// On any failure inside the transaction (validation, DB error, partial insert) -// the original cloud state is preserved. -// -// Audit B-03 fix: previous implementation ran `deleteMany` followed by -// `createMany` outside a transaction, so a single bad record could leave the -// user with an empty cloud account while their previous data was already gone. -export async function POST(request: Request) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const userId = session.user.id; - - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); - } - - const { campaigns } = (body ?? {}) as { campaigns?: unknown }; - if (!Array.isArray(campaigns)) { - return NextResponse.json({ error: 'Invalid data' }, { status: 400 }); - } - - // Reject obviously malformed rows up front so we never enter the - // transaction with input that cannot be written. Per-row validation stays - // inside the transaction so we still benefit from atomic rollback if the - // DB rejects something the JS layer can't catch. - const rows: Prisma.CampaignCreateManyInput[] = []; - for (const c of campaigns as any[]) { - if (!c || typeof c !== 'object') { - return NextResponse.json( - { error: 'Invalid campaign record: expected object' }, - { status: 400 }, - ); - } - const wire = c as WireCampaign; - if (typeof wire.id !== 'string' || wire.id.length === 0) { - return NextResponse.json( - { error: 'Invalid campaign record: missing string id' }, - { status: 400 }, - ); - } - rows.push({ - userId, - campaignId: wire.id, - type: c.type || 'SP', - name: c.name || 'Untitled', - portfolio: c.portfolio, - status: c.status || 'Enabled', - dailyBudget: c.dailyBudget ?? 25, - defaultBid: c.defaultBid ?? 0.75, - startDate: c.startDate, - endDate: c.endDate, - targetingMode: c.targetingMode, - adFormat: c.adFormat, - campaignGoal: c.campaignGoal, - bidStrategy: c.bidStrategy, - placements: c.placements ? JSON.stringify(c.placements) : null, - products: c.products ? JSON.stringify(c.products) : null, - creative: c.creative ? JSON.stringify(c.creative) : null, - metrics: c.metrics ? JSON.stringify(c.metrics) : null, - adGroups: c.adGroups ? JSON.stringify(c.adGroups) : null, - targets: c.targets ? JSON.stringify(c.targets) : null, - searchTerms: c.searchTerms ? JSON.stringify(c.searchTerms) : null, - negatives: c.negatives ? JSON.stringify(c.negatives) : null, - budgetRules: c.budgetRules ? JSON.stringify(c.budgetRules) : null, - history: c.history ? JSON.stringify(c.history) : null, - }); - } - - try { - const result = await prisma.$transaction(async (tx) => { - // Delete inside the transaction so the rollback path restores the - // user's previous snapshot. We do NOT touch other users' data because - // userId is in the where clause. - await tx.campaign.deleteMany({ where: { userId } }); - - // Empty payload is a valid "clear cloud" action: still atomic, still - // inside the transaction, still scoped to this user. - if (rows.length === 0) { - return { synced: 0 }; - } - - const created = await tx.campaign.createMany({ data: rows }); - return { synced: created.count }; - }); - - return NextResponse.json(result); - } catch (err) { - // Log on the server, do not leak DB internals to the client. - console.error('[sync] atomic replace failed; user cloud state preserved', { - userId, - error: err instanceof Error ? err.message : String(err), - }); - return NextResponse.json( - { error: 'Sync failed; previous cloud data preserved' }, - { status: 500 }, - ); - } -} - -// GET /api/sync - Load campaigns from database -export async function GET() { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const userId = session.user.id; - - const campaigns = await prisma.campaign.findMany({ - where: { userId }, - orderBy: { createdAt: 'desc' }, - }); - - // Parse JSON fields — a corrupted value in one row falls back to an - // empty default instead of throwing and 500ing the entire list. - const parsed = campaigns.map((c: any) => ({ - id: c.campaignId, - type: c.type, - name: c.name, - portfolio: c.portfolio, - status: c.status, - dailyBudget: c.dailyBudget, - defaultBid: c.defaultBid, - startDate: c.startDate, - endDate: c.endDate, - targetingMode: c.targetingMode, - adFormat: c.adFormat, - campaignGoal: c.campaignGoal, - bidStrategy: c.bidStrategy, - placements: safeJsonParse(c.placements, { top: 0, product: 0, rest: 0 }), - products: safeJsonParse(c.products, []), - creative: safeJsonParse(c.creative, null), - metrics: safeJsonParse(c.metrics, { impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 }), - adGroups: safeJsonParse(c.adGroups, []), - targets: safeJsonParse(c.targets, []), - searchTerms: safeJsonParse(c.searchTerms, []), - negatives: safeJsonParse(c.negatives, []), - budgetRules: safeJsonParse(c.budgetRules, []), - history: safeJsonParse(c.history, []), - })); - - return NextResponse.json(parsed); -} diff --git a/src/app/astryx-theme.css b/src/app/astryx-theme.css deleted file mode 100644 index a94c6d6..0000000 --- a/src/app/astryx-theme.css +++ /dev/null @@ -1,324 +0,0 @@ -/* ============================================================================ - * Astryx ↔ Amazon Platform Theme Bridge - * ---------------------------------------------------------------------------- - * Maps Astryx design tokens (--color-*, --font-family-*, --spacing-*, - * --radius-*, --shadow-*) onto the Amazon platform palette defined in - * globals.css (--surface-*, --ink-*, --accent, --success, --danger, - * --warning, --info, --font-display, --font-mono, --space-*, --radius-*, - * --shadow-*). - * - * Why this file exists - * The first Astryx migration (PRs #32-#36) regressed the visual theme - * in 3 places because Astryx components rendered with their own default - * neutral palette, conflicting with the Amazon colors. The `Theme` - * provider in `src/app/providers.tsx` sets `data-astryx-theme="neutral"` - * on its children wrapper, so the bridge targets that wrapper. - * - * Contract pinned by src/app/__tests__/astryx-theme.test.ts: - * - 13 color tokens are mapped to Amazon ink / surface / semantic - * variables. - * - Type, spacing, radius, and shadow tokens are mapped to the Amazon - * font, 4px-base spacing, 6-16px radius, and elevation scales. - * - The canonical Amazon token values in globals.css are NOT redefined - * here — the bridge only references them via var(--…), so visual - * identity is preserved. - * ============================================================================ */ - -[data-astryx-theme] { - /* ------------------------------------------------------------------------ - * Color — surfaces - * Amazon retail: page bg #eaeded, cards #ffffff, hover wells #f7f8f8. - * ---------------------------------------------------------------------- */ - --color-background-surface: var(--surface-1); - --color-background-body: var(--surface-0); - --color-background-card: var(--surface-1); - --color-background-popover: var(--surface-1); - --color-background-muted: var(--surface-2); - --color-background-inverted: var(--surface-3); - --color-background-error-inverted: var(--danger); - - /* ------------------------------------------------------------------------ - * Color — text - * Amazon text ramp: primary #0f1111, secondary #5f6b7a, disabled #8a93a0. - * ---------------------------------------------------------------------- */ - --color-text-primary: var(--ink-900); - --color-text-secondary: var(--ink-500); - --color-text-disabled: var(--ink-400); - --color-text-accent: var(--accent-active); - - --color-on-dark: var(--ink-inverse); - --color-on-light: var(--ink-900); - --color-on-accent: var(--accent-ink); - --color-on-success: var(--ink-inverse); - --color-on-error: var(--ink-inverse); - --color-on-warning: var(--accent-ink); - - /* ------------------------------------------------------------------------ - * Color — accent / brand - * Amazon orange #ff9900 is the only brand accent. - * ---------------------------------------------------------------------- */ - --color-accent: var(--accent); - --color-accent-muted: var(--accent-soft); - - /* ------------------------------------------------------------------------ - * Color — icons (mirror text ramp) - * ---------------------------------------------------------------------- */ - --color-icon-accent: var(--accent); - --color-icon-primary: var(--ink-900); - --color-icon-secondary: var(--ink-500); - --color-icon-disabled: var(--ink-400); - - /* ------------------------------------------------------------------------ - * Color — semantic - * Amazon palette: success teal #067d62, error red #cc0c39, warning #b12704, - * info teal #007185. - * ---------------------------------------------------------------------- */ - --color-success: var(--success); - --color-success-muted: var(--success-soft); - --color-error: var(--danger); - --color-error-muted: var(--danger-soft); - --color-warning: var(--warning); - --color-warning-muted: var(--warning-soft); - --color-info: var(--info); - --color-info-muted: var(--info-soft); - - /* ------------------------------------------------------------------------ - * Color — borders + hairlines - * Amazon retail: default border #d5d9d9, strong #8d9096, focus #007185. - * ---------------------------------------------------------------------- */ - --color-border: var(--border); - --color-border-emphasized: var(--border-strong); - --color-skeleton: var(--ink-200); - --color-track: var(--border); - --color-shadow: var(--shadow-sm); - - /* ------------------------------------------------------------------------ - * Color — neutral / overlay tints - * Use Amazon ink-900 tinted overlays so dialog scrims match the - * top-nav navy, not a generic black. - * ---------------------------------------------------------------------- */ - --color-neutral: color-mix(in srgb, var(--ink-900) 6%, transparent); - --color-overlay: color-mix(in srgb, var(--ink-900) 50%, transparent); - --color-overlay-hover: color-mix(in srgb, var(--ink-900) 4%, transparent); - --color-overlay-pressed: color-mix(in srgb, var(--ink-900) 10%, transparent); - - /* ------------------------------------------------------------------------ - * Typography — bind Astryx font slots to our Geist stack - * ---------------------------------------------------------------------- */ - --font-family-body: var(--font-body); - --font-family-heading: var(--font-display); - --font-family-code: var(--font-mono); - - /* ------------------------------------------------------------------------ - * Spacing — bridge Astryx's 0-12 token set to our 4px-base scale - * ---------------------------------------------------------------------- */ - --spacing-0: 0; - --spacing-0-5: 2px; - --spacing-1: var(--space-1); - --spacing-1-5: 6px; - --spacing-2: var(--space-2); - --spacing-3: var(--space-3); - --spacing-4: var(--space-4); - --spacing-5: var(--space-5); - --spacing-6: var(--space-6); - --spacing-7: 28px; - --spacing-8: var(--space-8); - --spacing-9: 36px; - --spacing-10: var(--space-10); - --spacing-11: 44px; - --spacing-12: var(--space-12); - - /* ------------------------------------------------------------------------ - * Radius — bridge Astryx's 5-step scale to our 6-16px Amazon scale - * ---------------------------------------------------------------------- */ - --radius-none: 0; - --radius-inner: var(--radius-sm); - --radius-element: var(--radius-md); - --radius-container: var(--radius-lg); - --radius-page: var(--radius-xl); - - /* ------------------------------------------------------------------------ - * Shadow — bridge to our elevation scale - * ---------------------------------------------------------------------- */ - --shadow-low: var(--shadow-sm); - --shadow-med: var(--shadow-md); - --shadow-high: var(--shadow-lg); - - /* Inset interaction shadows (Astryx focus rings) — use Amazon focus teal */ - --shadow-inset-hover: inset 0 0 0 2px var(--accent-soft); - --shadow-inset-selected: inset 0 0 0 2px var(--accent); - --shadow-inset-success: inset 0 0 0 2px var(--success-soft); - --shadow-inset-warning: inset 0 0 0 2px var(--warning-soft); - --shadow-inset-error: inset 0 0 0 2px var(--danger-soft); -} - -/* ============================================================================ - * Button variant styles — preserve Amazon platform look - * ---------------------------------------------------------------------------- - * Astryx ships with 4 stock variants (primary, secondary, ghost, destructive) - * whose base styles are emitted as StyleX classes in `@layer astryx-base`. - * Because that layer is lower-priority than unlayered styles, this block - * (which is unlayered) wins the cascade and gives each variant the exact - * Amazon look from the old .btn CSS. - * - * Custom variants declared in src/types/astryx-augment.d.ts (info, warning) - * are not styled by Astryx at all, so we define them here from scratch. - * - * Heights also need overriding — Astryx defaults to 28/32/36 (sm/md/lg) but - * our Amazon platform uses 32 (sm) / 36 (md) for in-page actions, so we - * tighten the default to 36 and pull the small up to 32. - * ============================================================================ */ - -/* Amazon secondary (was .btn — neutral, white surface, dark text) */ -.astryx-button[data-variant="secondary"] { - background: var(--surface-1); - color: var(--ink-900); - border: 1px solid var(--border); - font-weight: 500; -} -.astryx-button[data-variant="secondary"]:hover:not([aria-disabled="true"]) { - background: var(--surface-2); - border-color: var(--border-strong); - box-shadow: var(--shadow-xs); -} - -/* Amazon primary (was .btn.primary — Amazon orange, dark text) */ -.astryx-button[data-variant="primary"] { - background: var(--accent); - color: var(--accent-ink); - border-color: var(--accent); - font-weight: 600; -} -.astryx-button[data-variant="primary"]:hover:not([aria-disabled="true"]) { - background: var(--accent-hover); - border-color: var(--accent-hover); - box-shadow: var(--shadow-sm); -} -.astryx-button[data-variant="primary"]:active:not([aria-disabled="true"]) { - background: var(--accent-active); -} - -/* Amazon destructive (was .btn.danger — red OUTLINE, not filled like stock) */ -.astryx-button[data-variant="destructive"] { - background: transparent; - color: var(--danger); - border: 1px solid var(--danger); - font-weight: 500; -} -.astryx-button[data-variant="destructive"]:hover:not([aria-disabled="true"]) { - background: var(--danger-soft); - border-color: var(--danger); -} - -/* Amazon info (was .btn.blue — teal fill, white text) — custom variant */ -.astryx-button[data-variant="info"] { - background: var(--info); - color: #ffffff; - border: 1px solid var(--info); - font-weight: 500; -} -.astryx-button[data-variant="info"]:hover:not([aria-disabled="true"]) { - background: #005f73; - border-color: #005f73; -} - -/* Amazon warning (reserved for parity — was never used in JSX, but - defined so the variant works if a future component uses it) */ -.astryx-button[data-variant="warning"] { - background: var(--warning); - color: #ffffff; - border: 1px solid var(--warning); - font-weight: 500; -} -.astryx-button[data-variant="warning"]:hover:not([aria-disabled="true"]) { - background: color-mix(in srgb, var(--warning) 80%, black); - border-color: color-mix(in srgb, var(--warning) 80%, black); -} - -/* Amazon ghost — outline only, no fill (no JSX usage, reserved for parity) */ -.astryx-button[data-variant="ghost"] { - background: transparent; - color: var(--ink-700); - border: 1px solid transparent; - font-weight: 500; -} -.astryx-button[data-variant="ghost"]:hover:not([aria-disabled="true"]) { - background: var(--surface-2); - border-color: var(--border); -} - -/* Size overrides — Astryx defaults to 28/32/36 (sm/md/lg). Our Amazon - buttons used 36/32, so we map our default→md (36px) and .small→sm - (32px). Astryx's lg (also 36px) is unused in our code. */ -.astryx-button[data-size="md"] { - min-height: 36px; - padding-block: 6px; - padding-inline: 14px; - font-size: var(--text-sm); -} -.astryx-button[data-size="sm"] { - min-height: 32px; - padding-block: 4px; - padding-inline: 10px; - font-size: var(--text-xs); -} - -/* Shared disabled state — opacity 0.5 + not-allowed cursor (matches the - old .btn:disabled rule). Astryx sets [aria-disabled] when isDisabled is - true (NOT the native disabled attribute, so clicks can still be tested). */ -.astryx-button[aria-disabled="true"] { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; -} - -/* ============================================================================ - * Card variant styles — preserve Amazon platform look - * ---------------------------------------------------------------------------- - * Astryx Card ships with 12 stock variants (default + 11 colors) and a base - * `styles.card` StyleX class for the container shape. The base class uses - * --color-background-surface and --color-border via the theme, so the - * bridged tokens (--surface-1 and --border) give us the right background - * and border for free. - * - * These overrides are only needed to fine-tune the default border radius - * (Amazon uses --radius-lg = 12px) and to ensure the borderless variants - * (transparent, muted) collapse to match our spec. Astryx defaults to - * 8px radius; we bump to 12px. - * ============================================================================ */ -.astryx-card { - border-radius: var(--radius-lg); -} -.astryx-card[data-variant="muted"] { - background: var(--surface-2); -} - -/* ============================================================================ - * Table styles — preserve Amazon platform look - * ---------------------------------------------------------------------------- - * Astryx's `` (children mode) emits a stable `.astryx-table` class on - * the `
` element and wraps it in a `.astryx-table-scroll-wrapper` div - * (role="group", tabIndex=0 for keyboard scrolling). The global th/td/tr - * rules in globals.css already style the cells (sticky header, padding, - * hover rows, last-row no border, tabular-nums on `.mono`/`.money`), so the - * bridge only needs to give the scroll wrapper the card-in-table appearance - * the old `.table-wrap` had: overflow-x: auto, soft border, rounded corners, - * surface background, and a tighter mobile radius. - * - * Astryx defaults to 8px radius on most containers; our Amazon platform uses - * --radius-lg (12px) for table chrome, so we bump it explicitly. - * ============================================================================ */ - -.astryx-table-scroll-wrapper { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - border-radius: var(--radius-lg); - border: 1px solid var(--border); - background: var(--surface-1); - font-size: var(--text-sm); -} - -/* Note: the mobile `@media (max-width: 768px)` rule in globals.css already - overrides `.astryx-table-scroll-wrapper { border-radius: var(--radius-md); }` - (replacing the old `.table-wrap` selector). We don't repeat it here to keep - a single source of truth. */ diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx deleted file mode 100644 index 0c67f91..0000000 --- a/src/app/auth/login/page.tsx +++ /dev/null @@ -1,118 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Button } from '@astryxdesign/core/Button'; -import { signIn } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import Link from 'next/link'; - -export default function LoginPage() { - const router = useRouter(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [showPassword, setShowPassword] = useState(false); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setLoading(true); - - try { - const result = await signIn('credentials', { - email, - password, - redirect: false, - }); - - if (result?.error) { - setError('Invalid email or password'); - } else { - router.push('/'); - router.refresh(); - } - } catch { - setError('An error occurred'); - } finally { - setLoading(false); - } - }; - - return ( -
-
-
- Project Amazon PH -

Sign in to your training account

-
- -
-
- {error && ( -
{error}
- )} - -
- - setEmail(e.target.value)} - className="auth-input" - placeholder="you@example.com" - required - /> -
- -
- -
- setPassword(e.target.value)} - className="auth-input" - placeholder="••••••••" - required - /> - -
-
- -
- -

- Back to simulator -

-
-
- ); -} diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx deleted file mode 100644 index 0adbd32..0000000 --- a/src/app/auth/register/page.tsx +++ /dev/null @@ -1,174 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Button } from '@astryxdesign/core/Button'; -import { signIn } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import Link from 'next/link'; - -export default function RegisterPage() { - const router = useRouter(); - const [name, setName] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [showPassword, setShowPassword] = useState(false); - const [showConfirm, setShowConfirm] = useState(false); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - - const EyeIcon = ({ visible }: { visible: boolean }) => visible ? ( - - ) : ( - - ); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - - if (password !== confirmPassword) { - setError('Passwords do not match'); - return; - } - - if (password.length < 6) { - setError('Password must be at least 6 characters'); - return; - } - - setLoading(true); - - try { - const response = await fetch('/api/auth/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, email, password }), - }); - - const data = await response.json(); - - if (!response.ok) { - setError(data.error || 'Registration failed'); - return; - } - - const result = await signIn('credentials', { - email, - password, - redirect: false, - }); - - if (result?.error) { - setError('Account created but sign-in failed. Please try logging in.'); - } else { - router.push('/dashboard'); - router.refresh(); - } - } catch { - setError('An error occurred'); - } finally { - setLoading(false); - } - }; - - return ( -
-
-
- Project Amazon PH -

Create your training account

-
- -
-
- {error && ( -
{error}
- )} - -
- - setName(e.target.value)} - className="auth-input" - placeholder="Your name" - /> -
- -
- - setEmail(e.target.value)} - className="auth-input" - placeholder="you@example.com" - required - /> -
- -
- -
- setPassword(e.target.value)} - className="auth-input" - placeholder="••••••••" - required - minLength={6} - /> - -
-
- -
- -
- setConfirmPassword(e.target.value)} - className="auth-input" - placeholder="••••••••" - required - minLength={6} - /> - -
-
- -
- -

- Back to simulator -

-
-
- ); -} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx deleted file mode 100644 index 81b1041..0000000 --- a/src/app/dashboard/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { AdConsole } from '@/components/AdConsole/AdConsole'; - -export default function DashboardPage() { - return ; -} diff --git a/src/app/globals.css b/src/app/globals.css deleted file mode 100644 index 307f2bf..0000000 --- a/src/app/globals.css +++ /dev/null @@ -1,4066 +0,0 @@ -/* Astryx Design System — core imports */ -@import '@astryxdesign/core/reset.css'; -@import '@astryxdesign/core/astryx.css'; -@import '@astryxdesign/theme-neutral/theme.css'; -@import './astryx-theme.css'; - -/* Amazon Advertising Console — Premium Redesign - * Refined Amazon Ad Console aesthetic with modern design principles - * Light canvas, crisp surfaces, Amazon orange accent, premium typography - */ - -:root { - /* ============================================================================ - * Amazon Platform Theme — official brand tokens - * ---------------------------------------------------------------------------- - * Surfaces, nav, ink, borders, and accent all use Amazon's published palette - * (advertising.amazon.com + retail amazon.com shared design system). - * Refs: - * - Amazon retail: #131921 top nav, #eaeded page bg, #ff9900 accent - * - Amazon Ads: same accent + #007185 clickable teal for focus/links - * - AWS technical: error red #cc0c39, success green #1d8102 - * ============================================================================ */ - - /* Surfaces — Amazon retail canvas (page, panels, top nav, deeper nav) */ - --surface-0: #eaeded; /* Amazon page background (canonical #eaeded) */ - --surface-1: #ffffff; /* Cards, panels, modals */ - --surface-2: #f7f8f8; /* Subtle elevation, hover wells */ - --surface-3: #131921; /* Top nav + dark surfaces (Amazon retail dark navy) */ - --surface-4: #0f1111; /* Deepest surface, marketing page hero */ - - /* Ink (text) — Amazon's text ramp */ - --ink-900: #0f1111; /* Primary text on light surfaces */ - --ink-800: #16191e; /* Strong text */ - --ink-700: #2b3947; /* Section labels, secondary headings */ - --ink-500: #5f6b7a; /* Muted body, helper text */ - --ink-400: #8a93a0; /* Placeholder, disabled */ - --ink-300: #c7cbd0; /* Hairlines-on-light decorative */ - --ink-200: #e7e9ec; /* Subtle dividers */ - --ink-inverse: #ffffff; /* Text on dark surfaces */ - - /* Border — Amazon's hairline ramp */ - --border: #d5d9d9; /* Default border (matches Amazon retail) */ - --border-light: #e7e9ec; /* Lighter divider */ - --border-strong: #8d9096; /* Strong border, focus contrast */ - --border-focus: #007185; /* Amazon "clickable" teal — focus + links */ - - /* Brand — Amazon orange (canonical #ff9900) */ - --accent: #ff9900; - --accent-hover: #e47911; /* Canonical Amazon hover */ - --accent-active: #c45500; /* Canonical Amazon active */ - --accent-soft: #fef3e0; /* Tint for chips/wells */ - --accent-ink: #0f1111; /* Text on accent fills */ - - /* Semantic — Amazon platform pairings */ - --success: #067d62; /* Teal/green that Amazon Ads uses */ - --success-soft: #e6f4f1; - --warning: #b12704; /* Amazon warning (slightly hotter) */ - --warning-soft: #fef3e0; - --danger: #cc0c39; /* Amazon error red */ - --danger-soft: #fde7ee; - --info: #007185; /* Amazon clickable teal (same as focus) */ - --info-soft: #e0f2f5; - --purple: #7c3aed; /* SD campaign-type badge */ - --purple-soft: #f3e8ff; - - /* Typography — Premium font stack */ - --font-display: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - --font-body: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - --font-mono: 'Geist Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace; - - /* Type scale — Refined hierarchy */ - --text-2xs: 0.6875rem; - --text-xs: 0.75rem; - --text-sm: 0.875rem; - --text-base: 1rem; - --text-lg: 1.125rem; - --text-xl: 1.25rem; - --text-2xl: 1.5rem; - --text-3xl: 1.875rem; - --text-4xl: 2.25rem; - - /* Spacing — Consistent 4px base */ - --space-0: 0; - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-5: 20px; - --space-6: 24px; - --space-8: 32px; - --space-10: 40px; - --space-12: 48px; - --space-16: 64px; - - /* Radius — Refined corners */ - --radius-sm: 6px; - --radius-md: 8px; - --radius-lg: 12px; - --radius-xl: 16px; - --radius-full: 9999px; - - /* Shadows — Subtle, depth-aware */ - --shadow-xs: 0 1px 2px rgba(15, 17, 17, 0.05); - --shadow-sm: 0 1px 3px rgba(15, 17, 17, 0.08), 0 1px 2px rgba(15, 17, 17, 0.04); - --shadow-md: 0 4px 6px -1px rgba(15, 17, 17, 0.08), 0 2px 4px -2px rgba(15, 17, 17, 0.04); - --shadow-lg: 0 10px 15px -3px rgba(15, 17, 17, 0.08), 0 4px 6px -4px rgba(15, 17, 17, 0.04); - --shadow-xl: 0 20px 25px -5px rgba(15, 17, 17, 0.1), 0 8px 10px -6px rgba(15, 17, 17, 0.06); - - /* Z-index — Organized scale */ - --z-base: 0; - --z-dropdown: 100; - --z-sticky: 200; - --z-overlay: 300; - --z-modal: 400; - --z-toast: 500; - - /* Motion — Smooth, intentional */ - --duration-instant: 50ms; - --duration-fast: 120ms; - --duration-base: 200ms; - --duration-slow: 300ms; - --ease-out: cubic-bezier(0.16, 1, 0.3, 1); - --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); - --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); - - /* Global nav — Amazon retail dark navy (advertising.amazon.com) */ - --nav-bg: #131921; - --nav-bg-hover: #232f3e; - --nav-ink: #ffffff; - --nav-ink-dim: #b9c2cc; - - /* Legacy aliases */ - --bg: var(--surface-0); - --surface: var(--surface-1); - --ink: var(--ink-900); - --line: var(--border); - --blue: var(--info); - --blue-bg: var(--info-soft); - --green: var(--success); - --green-bg: var(--success-soft); - --red: var(--danger); - --red-bg: var(--danger-soft); - --amber: var(--warning); - --amber-bg: var(--warning-soft); - --orange: var(--accent); - --radius: var(--radius-md); - --shadow: var(--shadow-sm); - - color-scheme: light; -} - -/* Reset - * NOTE: padding is intentionally NOT zeroed on `*`. Astryx components ship - * their own padding as StyleX classes inside `@layer astryx-base`/ - * `@layer astryx-theme`; per the CSS cascade-layers spec, ANY unlayered - * declaration beats a layered one regardless of specificity. A blanket - * `* { padding: 0 }` here (unlayered) was silently zeroing out every - * Astryx `padding` prop (Card, etc.) sitewide. Reset padding only on the - * couple of native elements that actually need it. */ -*, *::before, *::after { box-sizing: border-box; margin: 0; } -ul, ol { padding: 0; } - -html { - font-family: var(--font-body); - font-size: 16px; - line-height: 1.5; - color: var(--ink-900); - background-color: var(--surface-0); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; -} - -body { - min-height: 100vh; - min-height: 100dvh; -} - -h1, h2, h3, h4, h5, h6 { - font-family: var(--font-display); - font-weight: 600; - line-height: 1.2; - letter-spacing: -0.02em; - color: var(--ink-900); - text-wrap: balance; -} - -a { - color: var(--info); - text-decoration: none; - transition: color var(--duration-fast) var(--ease-out); -} -a:hover { color: var(--accent); } -button { cursor: pointer; font: inherit; } -input, select, textarea { font: inherit; } - -code, pre { - font-family: var(--font-mono); - font-size: 0.9em; -} - -::selection { - background: var(--accent); - color: var(--accent-ink); -} - -/* Focus ring — Accessible, visible */ -:focus-visible { - outline: 2px solid var(--border-focus); - outline-offset: 2px; -} - -/* Skip link — hidden until focused, lets keyboard users jump past the nav */ -.skip-link { - position: absolute; - top: -40px; - left: var(--space-2); - background: var(--surface-3); - color: var(--ink-inverse); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-sm); - z-index: var(--z-modal); - text-decoration: none; - font-weight: 500; - transition: top 150ms cubic-bezier(0.32, 0.72, 0, 1); -} -.skip-link:focus { - top: var(--space-2); -} - -/* Tabular numerals on numeric tables and KPI values so columns line up */ -.table-wrap table, -.kpi-tile .value, -.metric-card .value, -.money, -.mono { - font-feature-settings: 'tnum' 1, 'cv11' 1; -} - -/* Visually hidden — keep available to screen readers and keyboard focus */ -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -/* -- Layout — Project Amazon PH: global nav on top, content below -- */ - -.app-layout { - display: flex; - flex-direction: column; - min-height: 100vh; - min-height: 100dvh; - background: var(--surface-0); -} - -.app-navbar { - background: var(--nav-bg); - color: var(--nav-ink); - height: 52px; - display: flex; - align-items: center; - padding: 0 var(--space-4); - gap: var(--space-1); - position: sticky; - top: 0; - z-index: var(--z-sticky); - flex-shrink: 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - overflow-x: auto; - scrollbar-width: none; -} - -.app-navbar::-webkit-scrollbar { - display: none; -} - -/* Trailing controls (sync/account/create) must keep their natural size — - letting them shrink collapses their text to two lines and pokes out of - the fixed-height navbar, overlapping neighboring controls. */ -.app-navbar > .nav-spacer ~ * { - flex-shrink: 0; -} - -.app-navbar .nav-brand { - display: flex; - align-items: baseline; - gap: 8px; - font-weight: 700; - font-size: var(--text-sm); - color: var(--nav-ink); - padding-right: var(--space-4); - margin-right: var(--space-3); - border-right: 1px solid rgba(255, 255, 255, 0.15); - white-space: nowrap; - letter-spacing: -0.01em; -} - -.app-navbar .nav-brand .brand-sub { - font-weight: 400; - font-size: var(--text-xs); - color: var(--nav-ink-dim); - letter-spacing: 0; -} - -.nav-section { - display: flex; - align-items: center; - height: 52px; - padding: 0 var(--space-3); - color: var(--nav-ink); - font-size: var(--text-sm); - font-weight: 500; - cursor: pointer; - border-bottom: 2px solid transparent; - transition: color, background-color, border-color, box-shadow, transform, opacity var(--duration-fast) var(--ease-out); - letter-spacing: -0.005em; -} - -.nav-section:hover { - background: var(--nav-bg-hover); - color: #fff; -} - -.nav-section.active { - border-bottom-color: var(--accent); - color: #fff; -} - -.nav-spacer { flex: 1; } - -.nav-account-wrap { - position: relative; - display: flex; - align-items: center; -} - -.nav-account { - display: flex; - align-items: center; - gap: var(--space-2); - color: var(--nav-ink); - font-size: var(--text-sm); - padding: 0 var(--space-2); - height: 52px; - background: none; - border: none; - cursor: pointer; - transition: background var(--duration-fast) var(--ease-out); -} -.nav-account:hover { background: var(--nav-bg-hover); } - -.nav-account-avatar { - display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - flex-shrink: 0; - border-radius: 50%; - background: rgba(255, 153, 0, 0.18); - color: var(--accent); - font-size: var(--text-sm); - font-weight: 600; -} - -.nav-account-name { - max-width: 140px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.nav-account-skeleton { - width: 30px; - height: 30px; - border-radius: 50%; - background: var(--nav-bg-hover); - animation: pulse 1.4s ease-in-out infinite; -} - -@keyframes pulse { - 0%, 100% { opacity: 0.6; } - 50% { opacity: 1; } -} - -.nav-account-backdrop { - position: fixed; - inset: 0; - z-index: var(--z-dropdown); -} - -.nav-account-menu { - position: fixed; - z-index: calc(var(--z-dropdown) + 1); - width: 220px; - background: var(--surface-1); - border: 1px solid var(--border-light); - border-radius: var(--radius-md); - box-shadow: var(--shadow-lg); - overflow: hidden; -} - -.nav-account-menu-header { - padding: var(--space-3); - border-bottom: 1px solid var(--border-light); -} - -.nav-account-menu-name { - font-size: var(--text-sm); - font-weight: 600; - color: var(--ink-900); -} - -.nav-account-menu-email { - font-size: var(--text-xs); - color: var(--ink-500); - margin-top: 2px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.nav-account-menu-item { - display: block; - width: 100%; - text-align: left; - padding: var(--space-3); - font-size: var(--text-sm); - color: var(--ink-700); - background: none; - border: none; - cursor: pointer; -} -.nav-account-menu-item:hover { - background: var(--surface-2); - color: var(--ink-900); -} - -.nav-sim-btn { - background: rgba(255, 255, 255, 0.07); - border: 1px solid rgba(255, 255, 255, 0.1); - color: var(--nav-ink); - font-size: var(--text-xs); - font-weight: 500; - padding: 0.3rem 0.75rem; - border-radius: var(--radius-md); - cursor: pointer; - white-space: nowrap; - transition: background 150ms; -} -.nav-sim-btn:hover { - background: rgba(255, 255, 255, 0.14); - color: var(--ink-inverse); -} - -.sync-controls { - display: flex; - align-items: center; - gap: var(--space-2); -} - -.sync-status { - font-size: var(--text-xs); - color: var(--nav-ink-dim); - white-space: nowrap; -} - -.sync-btn { - font-size: var(--text-xs); - font-weight: 500; - padding: var(--space-1) var(--space-3); - border-radius: var(--radius-lg, 999px); - border: none; - cursor: pointer; - white-space: nowrap; - transition: background var(--duration-fast) var(--ease-out); -} -.sync-btn:disabled { opacity: 0.5; cursor: not-allowed; } - -.sync-btn.save { - background: rgba(16, 185, 129, 0.12); - color: #34d399; -} -.sync-btn.save:hover:not(:disabled) { background: rgba(16, 185, 129, 0.22); } - -.sync-btn.load { - background: rgba(59, 130, 246, 0.12); - color: #60a5fa; -} -.sync-btn.load:hover:not(:disabled) { background: rgba(59, 130, 246, 0.22); } - -.app-body { - display: flex; - flex: 1; - min-height: 0; -} - -.app-sidebar { - width: 220px; - background: var(--surface-1); - border-right: 1px solid var(--border-light); - padding: var(--space-3) var(--space-2); - display: flex; - flex-direction: column; - gap: 1px; - overflow-y: auto; - flex-shrink: 0; -} - -.app-main { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; -} - -.app-topbar { - background: var(--surface-1); - border-bottom: 1px solid var(--border-light); - padding: var(--space-3) var(--space-5); - display: flex; - align-items: center; - justify-content: space-between; - min-height: 50px; -} - -.app-content { - padding: var(--space-6); - flex: 1; - overflow-y: auto; - overflow-x: hidden; - max-width: 1480px; - width: 100%; - margin: 0 auto; -} - -/* -- Sidebar ------------------------------------------------------ */ - -.sidebar-group-title { - font-size: var(--text-2xs); - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--ink-400); - font-weight: 700; - padding: var(--space-4) var(--space-3) var(--space-1); -} - -.sidebar-brand { - font-family: var(--font-display); - font-weight: 700; - font-size: var(--text-base); - padding: var(--space-2) var(--space-3); - margin-bottom: var(--space-2); - display: inline-flex; - align-items: center; - gap: var(--space-2); - color: var(--ink-900); - letter-spacing: -0.01em; -} - -.sidebar-item { - display: flex; - align-items: center; - gap: var(--space-2); - padding: 9px var(--space-3); - color: var(--ink-700); - font-size: var(--text-sm); - cursor: pointer; - border-left: 3px solid transparent; - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; - transition: color var(--duration-fast) var(--ease-out), - background-color var(--duration-fast) var(--ease-out), - border-color var(--duration-fast) var(--ease-out); - min-height: 36px; - font-weight: 400; -} - -.sidebar-item:hover { - background: var(--surface-2); - color: var(--ink-900); -} - -.sidebar-item.active { - background: var(--accent-soft); - color: var(--accent-active); - border-left-color: var(--accent); - font-weight: 600; -} - -.sidebar-spacer { flex: 1; } - -/* -- Page Title --------------------------------------------------- */ - -.page-title { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: var(--space-5); - gap: var(--space-4); -} - -.page-title h1 { - font-size: var(--text-xl); - font-weight: 700; - letter-spacing: -0.02em; - line-height: 1.15; -} - -.page-title p { - color: var(--ink-500); - font-size: var(--text-sm); - margin-top: 3px; -} - -.page-actions { - display: flex; - gap: var(--space-2); - flex-wrap: wrap; -} - -/* -- Toolbar ------------------------------------------------------ */ - -.toolbar { - display: flex; - gap: var(--space-2); - margin-bottom: var(--space-4); - flex-wrap: wrap; - align-items: center; - padding: var(--space-2) var(--space-3); - background: var(--surface-1); - border: 1px solid var(--border); - border-radius: var(--radius-lg); -} - -.toolbar .search { - flex: 1; - min-width: 220px; - position: relative; -} - -.toolbar .search::before { - content: ''; - position: absolute; - left: var(--space-3); - top: 50%; - width: 15px; - height: 15px; - transform: translateY(-50%); - background-color: var(--ink-400); - -webkit-mask: url("data:image/svg+xml;utf8,") center / contain no-repeat; - mask: url("data:image/svg+xml;utf8,") center / contain no-repeat; - pointer-events: none; -} - -.toolbar .search input { - width: 100%; - padding-left: calc(var(--space-3) + 15px + var(--space-2)); -} - -.toolbar .search.search-with-clear input { - padding-right: 2.5rem; -} - -.search-clear { - position: absolute; - right: 0.6rem; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - cursor: pointer; - color: var(--ink-400); - font-size: 1.1rem; - line-height: 1; - padding: 0.2rem; - border-radius: var(--radius-sm); - transition: color 150ms; -} -.search-clear:hover { - color: var(--ink-700); -} - -.cm-metrics-grid { - margin-bottom: var(--space-5); -} - -.reports-title { - margin-top: var(--space-2); -} - -.reports-actions { - display: flex; - gap: var(--space-2); -} - -.reports-queue-card { - margin-bottom: var(--space-4); -} - -/* -- Tabs --------------------------------------------------------- */ - -.tabs { - display: flex; - gap: 0; - border-bottom: 1px solid var(--border); - margin-bottom: var(--space-5); - overflow-x: auto; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; -} - -.tabs::-webkit-scrollbar { display: none; } - -.tab { - padding: var(--space-2) var(--space-4); - font-size: var(--text-sm); - font-weight: 500; - color: var(--ink-500); - border-bottom: 2px solid transparent; - cursor: pointer; - white-space: nowrap; - transition: color var(--duration-fast) var(--ease-out), - background-color var(--duration-fast) var(--ease-out), - border-color var(--duration-fast) var(--ease-out); - min-height: 40px; - display: flex; - align-items: center; - letter-spacing: -0.005em; -} - -.tab:hover { - color: var(--ink-900); - background: var(--surface-2); -} - -.tab.active { - color: var(--accent); - border-bottom-color: var(--accent); - font-weight: 600; -} - -/* -- Cards -------------------------------------------------------- */ - -.card { - background: var(--surface-1); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-xs); - transition: border-color var(--duration-fast) var(--ease-out), - box-shadow var(--duration-fast) var(--ease-out); -} - -.card:hover { - border-color: var(--border); - box-shadow: var(--shadow-sm); -} - -.card.pad { padding: var(--space-5); } - -.card-title { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--space-4); -} - -.card-title h2 { - font-size: var(--text-base); - font-weight: 600; - letter-spacing: -0.015em; -} - -.card-title span { - font-size: var(--text-xs); - color: var(--ink-400); - font-weight: 500; -} - -/* -- KPI Tiles ---------------------------------------------------- */ - -.kpi-grid { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: var(--space-3); - margin-bottom: var(--space-5); -} - -.kpi-tile { - position: relative; - background: var(--surface-1); - border: 1px solid var(--border-light); - border-radius: var(--radius-lg); - padding: var(--space-4) var(--space-4) var(--space-3); - display: flex; - flex-direction: column; - gap: var(--space-2); - transition: border-color var(--duration-fast) var(--ease-out), - box-shadow var(--duration-fast) var(--ease-out), - transform var(--duration-fast) var(--ease-out); - overflow: hidden; -} - -/* Left accent bar — always on .primary; reveal on hover for others */ -.kpi-tile::before { - content: ''; - position: absolute; - inset: 0 auto 0 0; - width: 3px; - background: var(--accent); - opacity: 0; - transition: opacity var(--duration-fast) var(--ease-out); -} - -.kpi-tile:hover { - border-color: var(--border); - box-shadow: var(--shadow-md); - transform: translateY(-1px); -} - -.kpi-tile:hover::before { opacity: 1; } - -/* Primary tiles always show the accent bar */ -.kpi-tile.primary { - background: var(--surface-1); - border-color: var(--border); -} -.kpi-tile.primary::before { opacity: 1; } - -.kpi-tile .label { - font-size: var(--text-2xs); - color: var(--ink-500); - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - display: flex; - align-items: center; - gap: var(--space-2); -} - -.kpi-tile .value { - font-size: var(--text-2xl); - font-weight: 700; - color: var(--ink-900); - font-variant-numeric: tabular-nums; - letter-spacing: -0.03em; - line-height: 1; -} - -.kpi-tile .delta { - font-size: var(--text-xs); - color: var(--ink-500); - margin-top: auto; - display: flex; - align-items: center; - gap: var(--space-1); -} - -.kpi-tile .delta.good { color: var(--success); } -.kpi-tile .delta.bad { color: var(--danger); } - -.kpi-trend { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - border-radius: var(--radius-full); - font-size: 9px; - font-weight: 700; -} -.kpi-trend.good { background: var(--success-soft); color: var(--success); } -.kpi-trend.bad { background: var(--danger-soft); color: var(--danger); } -.kpi-trend.flat { background: var(--surface-2); color: var(--ink-400); } - -/* -- Section Header (shared rhythm across pages) ------------------ */ - -.section-head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--space-4); - margin-bottom: var(--space-3); - padding-bottom: var(--space-2); - border-bottom: 1px solid var(--border-light); -} - -.section-head h2 { - font-size: var(--text-sm); - font-weight: 600; - letter-spacing: -0.01em; - color: var(--ink-900); -} - -.section-head .meta { - font-size: var(--text-xs); - color: var(--ink-400); - font-weight: 500; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -/* -- Tab Toolbar (inline action/form bar above a tab's table) ----- */ - -.tab-toolbar { - display: flex; - gap: var(--space-2); - align-items: flex-end; - flex-wrap: wrap; - margin-bottom: var(--space-4); -} - -.tab-toolbar.center { align-items: center; } -.tab-toolbar .field { margin-bottom: 0; } - -/* -- Timeline (change history) ------------------------------------ */ - -.timeline { - position: relative; - margin: var(--space-2) 0 0; - padding-left: var(--space-5); -} - -.timeline::before { - content: ''; - position: absolute; - left: 5px; - top: 6px; - bottom: 6px; - width: 2px; - background: var(--border); -} - -.timeline-item { - position: relative; - padding: 0 0 var(--space-4) var(--space-4); - font-size: var(--text-sm); - color: var(--ink-700); - line-height: 1.5; -} - -.timeline-item:last-child { padding-bottom: 0; } - -.timeline-item::before { - content: ''; - position: absolute; - left: -1px; - top: 5px; - width: 10px; - height: 10px; - border-radius: var(--radius-full); - background: var(--surface-1); - border: 2px solid var(--accent); -} - -/* -- Metric Cards ----------------------------------------------- */ - -.metric-card { - position: relative; - background: var(--surface-1); - border: 1px solid var(--border-light); - border-radius: var(--radius-lg); - padding: var(--space-4); - display: flex; - flex-direction: column; - gap: var(--space-2); - transition: border-color var(--duration-fast) var(--ease-out), - box-shadow var(--duration-fast) var(--ease-out), - transform var(--duration-fast) var(--ease-out); - overflow: hidden; -} - -.metric-card::before { - content: ''; - position: absolute; - inset: 0 auto 0 0; - width: 3px; - background: var(--accent); - opacity: 0; - transition: opacity var(--duration-fast) var(--ease-out); -} - -.metric-card:hover { - border-color: var(--border); - box-shadow: var(--shadow-md); - transform: translateY(-1px); -} - -.metric-card:hover::before { opacity: 1; } - -.metric-card .label { - font-size: var(--text-2xs); - color: var(--ink-500); - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; -} - -.metric-card .value { - font-size: var(--text-xl); - font-weight: 700; - color: var(--ink-900); - font-variant-numeric: tabular-nums; - letter-spacing: -0.02em; - line-height: 1.1; -} - -.metric-card .delta { - font-size: var(--text-xs); - color: var(--ink-500); - margin-top: auto; -} - -.metric-card .delta.good { color: var(--success); } -.metric-card .delta.bad { color: var(--danger); } - -/* -- Insight Cards ---------------------------------------------- */ - -.insight-list { - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.insight { - padding: var(--space-3) var(--space-3); - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; - font-size: var(--text-xs); - line-height: 1.5; -} - -.insight strong { - display: block; - margin-bottom: 2px; - font-weight: 600; - font-size: var(--text-sm); -} - -.insight.red { - background: var(--danger-soft); - border-left: 3px solid var(--danger); - color: var(--ink-700); -} - -.insight.orange { - background: var(--warning-soft); - border-left: 3px solid var(--warning); - color: var(--ink-700); -} - -.insight.green { - background: var(--success-soft); - border-left: 3px solid var(--success); - color: var(--ink-700); -} - -/* -- Grids -------------------------------------------------------- */ - -.grid-4 { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: var(--space-5); -} - -.grid-3 { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: var(--space-5); -} - -.grid-2 { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--space-5); -} - -.split { - display: grid; - grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); - gap: var(--space-5); -} - -/* -- Tables ------------------------------------------------------- */ - -/* (legacy .table-wrap rule removed in Phase 5 — the Astryx
scroll - wrapper now provides the overflow + border + radius + background; see - src/app/astryx-theme.css .astryx-table-scroll-wrapper block.) */ - -table { - width: 100%; - border-collapse: collapse; - font-size: var(--text-sm); -} - -th { - text-align: left; - padding: var(--space-2) var(--space-3); - border-bottom: 1px solid var(--border); - font-size: var(--text-2xs); - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--ink-400); - font-weight: 700; - white-space: nowrap; - background: var(--surface-2); - position: sticky; - top: 0; -} - -th:first-child { padding-left: var(--space-4); } - -td { - padding: var(--space-2) var(--space-3); - border-bottom: 1px solid var(--border-light); - white-space: nowrap; - vertical-align: middle; - color: var(--ink-900); -} - -td:first-child { padding-left: var(--space-4); } - -tr:last-child td { - border-bottom: none; -} - -tr:hover td { - background: var(--surface-2); -} - -.mono { - font-family: var(--font-mono); - font-size: var(--text-xs); - font-variant-numeric: tabular-nums; -} - -.money { - font-family: var(--font-mono); - font-size: var(--text-xs); - font-variant-numeric: tabular-nums; -} - -.muted { - color: var(--ink-400); - font-size: var(--text-xs); -} - -.good { color: var(--success); } -.warn { color: var(--warning); } -.bad { color: var(--danger); } - -/* -- Buttons ------------------------------------------------------ * - * .btn, .btn.primary, .btn.danger, .btn.blue, .btn.small — all moved - * to src/app/astryx-theme.css as the .astryx-button[data-variant="…"] - * overrides. The buttons now use / {c.name} - - -
-
-
-

{c.name}

-

- - - {c.type} - · - {c.targetingMode} - · - {c.portfolio} -

-
-
-
-
-
- -
- {tabs.map((tab) => ( - - ))} -
- - {selectedTab === 'overview' && } - {selectedTab === 'adgroups' && } - {selectedTab === 'targets' && } - {selectedTab === 'searchTerms' && } - {selectedTab === 'negatives' && } - {selectedTab === 'budgetRules' && } - {selectedTab === 'placements' && hasPlacements && } - {selectedTab === 'history' && } - - ); -} diff --git a/src/components/AdConsole/CampaignManager.tsx b/src/components/AdConsole/CampaignManager.tsx deleted file mode 100644 index 5dd8392..0000000 --- a/src/components/AdConsole/CampaignManager.tsx +++ /dev/null @@ -1,139 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Button } from '@astryxdesign/core/Button'; -import { Card } from '@astryxdesign/core/Card'; -import { useCampaignManager } from './hooks/useCampaignManager'; -import { MetricCard } from './metrics/MetricCard'; -import { calc, totalMetrics, formatMoney, formatWhole, formatPercent, formatBid, formatRoas, acosClass } from '@/engine/ad-console/core/engine'; -import type { FilterState } from '@/engine/ad-console/types'; -import { ManagerCampaignsTab } from './details/ManagerCampaignsTab'; -import { ManagerAdGroupsTab } from './details/ManagerAdGroupsTab'; -import { ManagerTargetsTab } from './details/ManagerTargetsTab'; -import { ManagerSearchTermsTab } from './details/ManagerSearchTermsTab'; -import { ManagerNegativesTab } from './details/ManagerNegativesTab'; - -export function CampaignManager() { - const { - campaigns, filteredCampaigns, filter, selectedTab, portfolioOptions, - setFilter, selectCampaign, setTab, toggleCampaignStatus, - duplicateCampaign, archiveCampaign, runSimulation, setView, - } = useCampaignManager(); - - const clearFilters = () => setFilter({ type: 'All', status: 'All', portfolio: 'All', search: '' }); - - const [simulating, setSimulating] = useState(false); - - const handleSimulate = async () => { - setSimulating(true); - await new Promise((r) => setTimeout(r, 50)); - runSimulation(); - await new Promise((r) => setTimeout(r, 600)); - setSimulating(false); - }; - - return ( -
- {simulating && ( -
- -
-
-
-

Running simulation…

-

Generating performance data for enabled campaigns.

- -
- )} - -
-
-

Campaign manager

-

Practice the core ads console flow: filter, inspect, optimize, create, and report.

-
-
- -
-
- - setFilter({ search: e.target.value })} - /> - {filter.search && ( - - )} -
- - - - - - -
- -
- {(() => { - const m = totalMetrics(filteredCampaigns); - const x = calc(m); - const acosTone = x.acos <= 0 ? '' : x.acos <= 30 ? 'good' : 'bad'; - return ( - <> - - 0 ? 'good' : ''} /> - 0 ? formatPercent(x.acos) : '—'} delta={x.acos > 0 ? `ROAS ${x.roas.toFixed(2)}×` : 'No spend yet'} tone={acosTone} /> - - - ); - })()} -
- -
- {['campaigns', 'adgroups', 'targets', 'searchTerms', 'negatives'].map((tab) => ( - - ))} -
- - {selectedTab === 'campaigns' && ( - 0} - onSelect={selectCampaign} - onToggleStatus={toggleCampaignStatus} - onDuplicate={duplicateCampaign} - onArchive={archiveCampaign} - onCreate={() => setView('create')} - onClearFilters={clearFilters} - /> - )} - {selectedTab === 'adgroups' && } - {selectedTab === 'targets' && } - {selectedTab === 'searchTerms' && } - {selectedTab === 'negatives' && } -
- ); -} diff --git a/src/components/AdConsole/Dashboard.tsx b/src/components/AdConsole/Dashboard.tsx deleted file mode 100644 index bd1ea15..0000000 --- a/src/components/AdConsole/Dashboard.tsx +++ /dev/null @@ -1,250 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; -import { Button } from '@astryxdesign/core/Button'; -import { Table } from '@astryxdesign/core/Table'; -import { Card } from '@astryxdesign/core/Card'; -import { ChartBar } from '@phosphor-icons/react'; -import { useAdConsoleStore } from '@/engine/ad-console/store'; -import { getKpiTiles } from './nav/consoleNav'; -import { calc, formatMoney, formatWhole, formatPercent, acosClass } from '@/engine/ad-console/core/engine'; -import type { Campaign, DerivedMetrics, ConsoleView, Metrics } from '@/engine/ad-console/types'; -import { useBreakpoint } from '@/lib/useBreakpoint'; -import { CampaignCard } from './mobile/CampaignCard'; - -export function Dashboard() { - const state = useAdConsoleStore((s) => s.state); - const setView = useAdConsoleStore((s) => s.setView); - const selectCampaign = useAdConsoleStore((s) => s.selectCampaign); - const { isMobile } = useBreakpoint(); - const totalMetrics = useAdConsoleStore((s) => s.totalMetricsCalc); - - // state.campaigns keeps the same array reference for any state change - // that doesn't touch campaigns (filter/tab/mobile-menu toggles, etc.), - // so this skips recomputing the full-campaign-list aggregate on those. - const m = useMemo(() => totalMetrics(), [state.campaigns]); // eslint-disable-line react-hooks/exhaustive-deps - const d = useMemo(() => calc(m), [m]); - const tiles = useMemo( - () => - getKpiTiles({ - impressions: m.impressions, - clicks: m.clicks, - spend: m.spend, - sales: m.sales, - orders: m.orders, - units: m.orders, - }), - [m], - ); - - const enabledCount = state.campaigns.filter((c) => c.status === 'Enabled').length; - const acosHealthy = d.acos > 0 && d.acos <= 30; - - return ( -
-
-
-

Advertising dashboard

-

- Performance across all enabled campaigns · {state.campaigns[0]?.portfolio ?? 'Default'} US -

-
-
- -
- {tiles.map((t) => { - const isPrimary = t.key === 'sales' || t.key === 'spend' || t.key === 'acos'; - const delta = kpiDelta(t.key, m, d, formatWhole); - return ( -
-
{t.label}
-
{t.value}
- {delta && ( -
- {delta.tone !== '' && ( - - {delta.tone === 'good' ? '↑' : delta.tone === 'bad' ? '↓' : '·'} - - )} - {delta.text} -
- )} -
- ); - })} -
- -
-
-
-

Campaigns

- {enabledCount} enabled · {state.campaigns.length} total -
- {isMobile ? ( -
- {state.campaigns.slice(0, 8).map((c) => ( - - ))} -
- ) : ( - renderCampaignTable(state.campaigns.slice(0, 8), selectCampaign, calc, setView) - )} -
-
- -
-

Operator alerts

- {acosHealthy ? 'On track' : 'Action needed'} -
-
-
- Waste detected - SP Auto has search terms with spend and zero orders. Open Search terms and add negatives. -
-
- SB creative review - Paused SB Video campaign is ready for a relaunch exercise after a creative check. -
-
- Remarketing winner - SD Views Remarketing has strong ROAS. Good campaign for budget rule practice. -
-
-
- -
-

Training coverage

- Core modules -
-
- Sponsored Products - Sponsored Brands - Sponsored Display - Search term harvesting - Negatives - Budget rules - Placement controls -
-
-
-
-
- ); -} - -type Tone = '' | 'good' | 'bad'; - -function kpiDelta( - key: string, - m: Metrics, - d: DerivedMetrics, - whole: (n: number) => string, -): { text: string; tone: Tone } | null { - switch (key) { - case 'acos': - return d.acos <= 0 - ? { text: 'No spend yet', tone: '' } - : d.acos <= 30 - ? { text: 'Healthy ACoS', tone: 'good' } - : { text: 'Above target', tone: 'bad' }; - case 'roas': - return d.roas <= 0 - ? { text: 'Sales ÷ spend', tone: '' } - : d.roas >= 3 - ? { text: 'Strong return', tone: 'good' } - : { text: 'Below 3× target', tone: 'bad' }; - case 'ctr': - return { text: `${whole(m.clicks)} clicks`, tone: '' }; - case 'sales': - return { text: `${m.orders} orders`, tone: m.orders > 0 ? 'good' : '' }; - case 'spend': - return { text: `CPC $${d.cpc.toFixed(2)}`, tone: '' }; - default: - return { text: `${m.orders} orders`, tone: '' }; - } -} - -function renderCampaignTable( - campaigns: Campaign[], - selectCampaign: (id: string) => void, - calc: (m: Metrics) => DerivedMetrics, - setView?: (view: ConsoleView) => void, -) { - if (!campaigns.length) { - return ( -
- -

No campaigns yet

-

Your advertising journey starts here. Create your first campaign to see performance data.

-
- ); - } - - return ( -
- - - - - - - - - - - - - - - - {campaigns.map((c: Campaign) => { - const x = calc(c.metrics); - return ( - - - - - - - - - - - - - ); - })} - -
CampaignTypeStatusBudgetTargetingImpr.ClicksSpendSalesACOS
- -
{c.portfolio}
-
- - {c.type} - - - - {c.status} - - {formatMoney(c.dailyBudget)} - {c.targetingMode} - {formatWhole(c.metrics.impressions)}{formatWhole(c.metrics.clicks)}{formatMoney(c.metrics.spend)}{formatMoney(c.metrics.sales)}{formatPercent(x.acos)}
- ); -} diff --git a/src/components/AdConsole/ErrorBoundary.tsx b/src/components/AdConsole/ErrorBoundary.tsx deleted file mode 100644 index 18be250..0000000 --- a/src/components/AdConsole/ErrorBoundary.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client'; - -import React from 'react'; -import { Button } from '@astryxdesign/core/Button'; - -interface Props { - children: React.ReactNode; -} - -interface State { - hasError: boolean; - error: Error | null; -} - -export class ErrorBoundary extends React.Component { - constructor(props: Props) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): State { - return { hasError: true, error }; - } - - render() { - if (this.state.hasError) { - return ( -
-
- ⚠️ -

Something went wrong

-

{this.state.error?.message || 'An unexpected error occurred.'}

-
-
- ); - } - return this.props.children; - } -} diff --git a/src/components/AdConsole/PortfolioOverview.tsx b/src/components/AdConsole/PortfolioOverview.tsx deleted file mode 100644 index 6ab86af..0000000 --- a/src/components/AdConsole/PortfolioOverview.tsx +++ /dev/null @@ -1,178 +0,0 @@ -'use client'; - -import { useState, useMemo } from 'react'; -import { Button } from '@astryxdesign/core/Button'; -import { Table } from '@astryxdesign/core/Table'; -import { Card } from '@astryxdesign/core/Card'; -import { useAdConsoleStore } from '@/engine/ad-console/store'; -import { calc, totalMetrics as sumMetrics, formatMoney, formatWhole, formatPercent, formatRoas, acosClass } from '@/engine/ad-console/core/engine'; - -export function PortfolioOverview() { - const state = useAdConsoleStore((s) => s.state); - const selectCampaign = useAdConsoleStore((s) => s.selectCampaign); - const createPortfolio = useAdConsoleStore((s) => s.createPortfolio); - const renamePortfolio = useAdConsoleStore((s) => s.renamePortfolio); - const deletePortfolio = useAdConsoleStore((s) => s.deletePortfolio); - const assignCampaignToPortfolio = useAdConsoleStore((s) => s.assignCampaignToPortfolio); - - const [manageMode, setManageMode] = useState(false); - const [newName, setNewName] = useState(''); - const [renameMap, setRenameMap] = useState>({}); - const [assignMap, setAssignMap] = useState>({}); - - const portfolios = useMemo(() => { - const map = new Map(); - state.campaigns.forEach((c) => { - const p = c.portfolio || '(No portfolio)'; - if (!map.has(p)) map.set(p, []); - map.get(p)!.push(c); - }); - return Array.from(map.entries()).map(([name, camps]) => ({ - name, - campaigns: camps, - metrics: sumMetrics(camps), - })); - }, [state.campaigns]); - - const totalMetrics = useMemo(() => sumMetrics(state.campaigns), [state.campaigns]); - - const totalDerived = calc(totalMetrics); - - return ( -
-
-
-

Portfolios

-

Group campaigns into portfolios and manage portfolio structure.

-
- -
- - {manageMode && ( - -

Create portfolio

New portfolio group
-
-
- - setNewName(e.target.value)} placeholder="e.g. Holiday Campaigns" /> -
-
-
- )} - -
- -
Total portfolios
-
{portfolios.length}
-
- -
Total spend (all)
-
{formatMoney(totalMetrics.spend)}
-
- -
Total sales (all)
-
{formatMoney(totalMetrics.sales)}
-
{formatWhole(totalMetrics.orders)} orders
-
- -
Blended ACOS
-
{formatPercent(totalDerived.acos)}
-
ROAS {formatRoas(totalDerived.roas)}
-
-
- - {portfolios.length === 0 ? ( -

No portfolios

Create a campaign with a portfolio name or use the Manage button above.

- ) : ( - portfolios.map((pf) => { - const x = calc(pf.metrics); - return ( -
- -
- {manageMode ? ( -
- - setRenameMap((m) => ({ ...m, [pf.name]: e.target.value }))} - onBlur={(e) => { - const v = e.target.value.trim(); - if (v && v !== pf.name) renamePortfolio(pf.name, v); - }} /> - {pf.campaigns.length} campaign{pf.campaigns.length !== 1 ? 's' : ''} -
- ) : ( - <> -

{pf.name}

- {pf.campaigns.length} campaign{pf.campaigns.length !== 1 ? 's' : ''} - - )} -
-
-
Spend
{formatMoney(pf.metrics.spend)}
-
Sales
{formatMoney(pf.metrics.sales)}
-
{formatPercent(x.acos)}
ACOS
-
{formatRoas(x.roas)}
ROAS
-
-
- - - - - {manageMode && } - - - - {pf.campaigns.map((c) => { - const cx = calc(c.metrics); - return ( - - - - - - - - - {manageMode && ( - - )} - - ); - })} - -
CampaignTypeStatusBudgetSpendSalesACOSAssign to
- - {c.type}{c.status}{formatMoney(c.dailyBudget)}{formatMoney(c.metrics.spend)}{formatMoney(c.metrics.sales)}{formatPercent(cx.acos)} - -
-
- ); - }) - )} -
- ); -} diff --git a/src/components/AdConsole/__tests__/CampaignCard.test.tsx b/src/components/AdConsole/__tests__/CampaignCard.test.tsx deleted file mode 100644 index c9f51b0..0000000 --- a/src/components/AdConsole/__tests__/CampaignCard.test.tsx +++ /dev/null @@ -1,152 +0,0 @@ -/** - * TDD tests for CampaignCard (mobile campaign list item). - * - * Per MOBILE_REDESIGN_PLAN Phase 1: at <768px, each campaign renders as - * a full-width card instead of a row in a table. Primary face shows - * Name + Type + Status; secondary metrics expand on demand. - * - * Money is rendered with thousand separators and 2dp; ROAS is shown - * to 2dp. Status uses the existing pill color system (green / orange). - * - * Secondary metrics (CPC, Orders, ACOS) live behind a `
` - * element with an aria-expanded toggle so screen readers and keyboard - * users can access them without losing context. - */ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { CampaignCard } from '../mobile/CampaignCard'; -import type { Campaign } from '@/engine/ad-console/types'; - -const baseMetrics = { - impressions: 10_000, - clicks: 500, - spend: 1234.56, - sales: 4567.89, - orders: 87, -}; - -function makeCampaign(overrides: Partial = {}): Campaign { - return { - id: 'camp-test', - type: 'SP', - name: 'Spring Sale - Auto', - portfolio: 'Default', - status: 'Enabled', - dailyBudget: 35, - defaultBid: 0.75, - startDate: '2026-01-01', - endDate: null, - targetingMode: 'Automatic', - adFormat: 'Standard', - bidStrategy: 'Dynamic bids - down only', - placements: { top: 0, product: 0, rest: 0 }, - products: ['B07TESTASIN'], - creative: null, - metrics: baseMetrics, - adGroups: [], - targets: [], - searchTerms: [], - negatives: [], - budgetRules: [], - productAds: [], - ads: [], - history: [], - createdBySimulator: true, - ...overrides, - } as Campaign; -} - -describe('CampaignCard - skeleton', () => { - it('renders the campaign name', () => { - render( {}} />); - expect(screen.getByText(/Spring Sale/)).toBeTruthy(); - }); - - it('renders the status as text', () => { - render( {}} />); - expect(screen.getByText('Enabled')).toBeTruthy(); - }); -}); - -describe('CampaignCard - primary metrics', () => { - it('renders the campaign type as a pill', () => { - render( {}} />); - expect(screen.getByText('SP')).toBeTruthy(); - }); - - it('renders spend and sales formatted as money', () => { - render( {}} />); - expect(screen.getByText(/\$1,234\.56/)).toBeTruthy(); - expect(screen.getByText(/\$4,567\.89/)).toBeTruthy(); - }); - - it('renders ROAS as a primary metric (sales/spend to 2dp)', () => { - render( {}} />); - expect(screen.getByText(/3\.70/)).toBeTruthy(); - }); - - it('uses a green pill class for Enabled status', () => { - render( {}} />); - const pill = screen.getByText('Enabled'); - expect(pill.className).toMatch(/pill/); - expect(pill.className).toMatch(/green/); - }); - - it('uses orange pill class for Paused status', () => { - render( {}} />); - const pill = screen.getByText('Paused'); - expect(pill.className).toMatch(/pill/); - expect(pill.className).toMatch(/orange/); - }); -}); - -describe('CampaignCard - expandable secondary', () => { - it('hides CPC / Orders / ACOS behind an expandable region', () => { - render( {}} />); - // ACOS = spend / sales * 100 = 27.02 (rounded to 2dp) - expect(screen.queryByText(/ACOS/i)).toBeNull(); - }); - - it('renders a toggle button with aria-expanded', () => { - render( {}} />); - const toggle = screen.getByRole('button', { name: /show details|hide details|details/i }); - expect(toggle.getAttribute('aria-expanded')).toBe('false'); - }); - - it('reveals ACOS, CPC, Orders when expanded', async () => { - const user = userEvent.setup(); - render( {}} />); - const toggle = screen.getByRole('button', { name: /show details|hide details|details/i }); - await user.click(toggle); - expect(toggle.getAttribute('aria-expanded')).toBe('true'); - expect(screen.getByText(/ACOS/i)).toBeTruthy(); - expect(screen.getByText(/CPC/i)).toBeTruthy(); - expect(screen.getByText(/Orders/i)).toBeTruthy(); - }); - - it('exposes Pause and Archive action buttons when expanded', async () => { - const user = userEvent.setup(); - render( {}} />); - const toggle = screen.getByRole('button', { name: /show details|hide details|details/i }); - await user.click(toggle); - expect(screen.getByRole('button', { name: /pause/i })).toBeTruthy(); - expect(screen.getByRole('button', { name: /archive/i })).toBeTruthy(); - }); - - it('calls onToggleStatus with the campaign id when Pause is clicked', async () => { - const user = userEvent.setup(); - const onToggleStatus = vi.fn(); - render( - {}} - onToggleStatus={onToggleStatus} - />, - ); - const toggle = screen.getByRole('button', { name: /show details|hide details|details/i }); - await user.click(toggle); - await user.click(screen.getByRole('button', { name: /pause/i })); - expect(onToggleStatus).toHaveBeenCalledWith('camp-test'); - }); -}); diff --git a/src/components/AdConsole/__tests__/Dashboard.test.tsx b/src/components/AdConsole/__tests__/Dashboard.test.tsx deleted file mode 100644 index ec24b46..0000000 --- a/src/components/AdConsole/__tests__/Dashboard.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/** - * TDD tests for Dashboard breakpoint branching. - * - * Per MOBILE_REDESIGN_PLAN Phase 1: when useBreakpoint().isMobile is true - * the Dashboard renders CampaignCard list items instead of a . - * Tablet and desktop paths must remain unchanged (still use the Table). - * - * Strategy: mock @/lib/useBreakpoint to control isMobile per test, then - * assert which DOM shape is produced. We also assert that onSelect - * propagates from the card to the store's selectCampaign. - */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, within } from '@testing-library/react'; -import { SessionProvider } from 'next-auth/react'; - -// Mock the breakpoint hook so each test can pick its viewport. -const mockUseBreakpoint = vi.fn(); -vi.mock('@/lib/useBreakpoint', () => ({ - useBreakpoint: () => mockUseBreakpoint(), -})); - -import { Dashboard } from '../Dashboard'; -import { useAdConsoleStore } from '@/engine/ad-console/store'; - -function defaultBreakpoint() { - return { - breakpoint: 'desktop' as const, - isMobile: false, - isTablet: false, - isDesktop: true, - isTouch: false, - }; -} - -function mobileBreakpoint() { - return { - breakpoint: 'mobile' as const, - isMobile: true, - isTablet: false, - isDesktop: false, - isTouch: true, - }; -} - -function renderDashboard() { - return render( - - - , - ); -} - -beforeEach(() => { - useAdConsoleStore.getState().resetAll(); - mockUseBreakpoint.mockReset(); -}); - -describe('Dashboard - breakpoint branching', () => { - it('renders the table on desktop (no CampaignCard articles)', () => { - mockUseBreakpoint.mockReturnValue(defaultBreakpoint()); - const { container } = renderDashboard(); - expect(container.querySelector('table')).not.toBeNull(); - expect(container.querySelector('.campaign-card')).toBeNull(); - }); - - it('renders CampaignCard articles on mobile (no table)', () => { - mockUseBreakpoint.mockReturnValue(mobileBreakpoint()); - const { container } = renderDashboard(); - expect(container.querySelector('table')).toBeNull(); - const cards = container.querySelectorAll('.campaign-card'); - expect(cards.length).toBeGreaterThan(0); - }); - - it('renders one CampaignCard per campaign on mobile', () => { - mockUseBreakpoint.mockReturnValue(mobileBreakpoint()); - const { container } = renderDashboard(); - const state = useAdConsoleStore.getState().state; - const cards = container.querySelectorAll('.campaign-card'); - // Store may slice to 8 like the existing renderCampaignTable does. - // Either way the count must equal the visible slice. - expect(cards.length).toBe(Math.min(8, state.campaigns.length)); - }); - - it('clicking a CampaignCard select button triggers store.selectCampaign', async () => { - mockUseBreakpoint.mockReturnValue(mobileBreakpoint()); - const { container } = renderDashboard(); - const firstCard = container.querySelector('.campaign-card'); - expect(firstCard).not.toBeNull(); - const selectBtn = firstCard!.querySelector('.campaign-card__select') as HTMLButtonElement; - expect(selectBtn).not.toBeNull(); - selectBtn.click(); - const state = useAdConsoleStore.getState().state; - expect(state.selectedCampaignId).not.toBeNull(); - }); - - it('still renders KPI tiles on mobile (KPI grid does not disappear)', () => { - mockUseBreakpoint.mockReturnValue(mobileBreakpoint()); - const { container } = renderDashboard(); - expect(container.querySelector('.kpi-grid')).not.toBeNull(); - }); -}); diff --git a/src/components/AdConsole/__tests__/ManagerCampaignsTab.test.tsx b/src/components/AdConsole/__tests__/ManagerCampaignsTab.test.tsx deleted file mode 100644 index 525bc7d..0000000 --- a/src/components/AdConsole/__tests__/ManagerCampaignsTab.test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Audit H-02: campaign manager metrics were displayed under the wrong - * column headings. The row's 4-column metric block (CPC / Spend / Sales / - * Orders) was rendered shifted left by one, so: - * - the "CPC" column actually showed spend - * - the "Spend" column actually showed sales - * - the "Sales" column actually showed orders - * - the "Orders" column actually showed cpc - * - * This test pins each column to the value it should display. The fixture - * uses values that are pairwise distinct so a misalignment cannot pass - * by accident. - */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, within } from '@testing-library/react'; -import { ManagerCampaignsTab } from '../details/ManagerCampaignsTab'; -import type { Campaign } from '@/engine/ad-console/types'; - -const METRICS = { - impressions: 10000, - clicks: 100, - spend: 25, // $25 spend - sales: 200, // $200 sales (distinct from spend) - orders: 4, // 4 orders (distinct from clicks) -}; -// Derived: cpc = 25 / 100 = $0.25 -// ctr = 100 / 10000 = 1.0% -// acos = 25 / 200 = 12.5% -// roas = 200 / 25 = 8.00x -// cvr = 4 / 100 = 4.0% -// These are all distinct, so swapping columns will be caught. - -const FIXTURE_CAMPAIGN: Campaign = { - id: 'cmp-1', - type: 'SP', - name: 'Audit Fixture', - portfolio: 'Default', - status: 'Enabled', - dailyBudget: 50, - defaultBid: 0.75, - startDate: '2026-01-01', - endDate: null, - targetingMode: 'Manual keyword', - adFormat: 'Standard', - bidStrategy: 'Fixed bids', - placements: { top: 0, product: 0, rest: 0 }, - products: [], - creative: null, - metrics: METRICS, - adGroups: [], - targets: [], - searchTerms: [], - negatives: [], - budgetRules: [], - productAds: [], - ads: [], - history: [], -}; - -const noop = () => undefined; - -beforeEach(() => { - // The Archive button uses window.confirm — jsdom defaults to true, - // but make it explicit so future changes can't quietly break this. - vi.spyOn(window, 'confirm').mockReturnValue(true); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('ManagerCampaignsTab — column alignment (H-02)', () => { - it('renders a row with the right metric under each column header', () => { - const { container } = render( - , - ); - - // Locate the first data row and grab every
in order. - const tbody = container.querySelector('tbody'); - expect(tbody).not.toBeNull(); - const cells = tbody!.querySelectorAll('td'); - expect(cells.length).toBeGreaterThan(0); - - // Headers are: Campaign, Type, Creative, Status, Budget, Targeting, - // Impr., Clicks, CPC, Spend, Sales, Orders, ACOS, ROAS, Actions - // (15 columns, 15 cells expected) - expect(cells.length).toBe(15); - - const cellText = (idx: number) => cells[idx]?.textContent?.trim() ?? ''; - - // The bug: CPC column showed $25.00 (spend), Spend showed $200.00 (sales), - // Sales showed "4" (orders), Orders showed "$0.25" (cpc). - // After fix: - expect(cellText(8)).toBe('$0.25'); // CPC - expect(cellText(9)).toBe('$25.00'); // Spend - expect(cellText(10)).toBe('$200.00');// Sales - expect(cellText(11)).toBe('4'); // Orders - expect(cellText(12)).toBe('12.5%'); // ACOS - expect(cellText(13)).toBe('8.00x'); // ROAS - }); - - it('shows the "create your first campaign" empty state when there are no campaigns at all', () => { - render( - , - ); - expect(screen.getByText('No campaigns yet')).toBeDefined(); - }); - - it('shows a "no matches" empty state (not "create your first campaign") when filters yield zero results', () => { - render( - , - ); - expect(screen.getByText('No campaigns match your filters')).toBeDefined(); - expect(screen.queryByText('No campaigns yet')).toBeNull(); - expect(screen.getByText('Clear filters')).toBeDefined(); - }); - - it('renders one row per campaign', () => { - const a = { ...FIXTURE_CAMPAIGN, id: 'a', name: 'Alpha' }; - const b = { ...FIXTURE_CAMPAIGN, id: 'b', name: 'Bravo' }; - const { container } = render( - , - ); - const rows = container.querySelectorAll('tbody tr'); - expect(rows.length).toBe(2); - expect(within(rows[0] as HTMLElement).getByText('Alpha')).toBeDefined(); - expect(within(rows[1] as HTMLElement).getByText('Bravo')).toBeDefined(); - }); -}); diff --git a/src/components/AdConsole/__tests__/a11y.test.tsx b/src/components/AdConsole/__tests__/a11y.test.tsx deleted file mode 100644 index 717f8fe..0000000 --- a/src/components/AdConsole/__tests__/a11y.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Regression tests for the a11y-taste pass. - * - * Pins the P0/P1 structural fixes so future refactors don't accidentally - * regress the keyboard / landmark surface: - * - * - AdConsole renders a
landmark with id="main-content" - * - AdConsole renders a skip link pointing at that main landmark - * - Topbar nav sections are