Build an Agentic CRUD Application

Build and validate a full-stack CRUD app with REST APIs and a Gemini agent.

Introduction

30 Second Summary

Campaign work often scatters budgets, audiences, performance numbers, and copy drafts across different tabs. Each update creates another chance for the latest information to get lost.

In this project, you will build and validate a demo-ready CRUD application with HTML, CSS, JavaScript, React, Node.js, Express, and REST APIs. You will add a Gemini agent, then map the high-level design, UX flow, and data flow.

What You'll Build

Your finished demo keeps a newly created campaign after a browser refresh while structured AI content appears beside the updated metrics.

By the end of this project, you'll have:

  • A demo-ready campaign dashboard where you can create, read, update, and delete records while metrics respond immediately.
  • A validated REST workflow where React sends requests to Express and displays useful success or error states.
  • A confirmation-gated campaign agent that inspects campaign data, proposes changes, and executes approved actions. You will also display high-level design, UX, and data-flow diagrams.
  • Secret Mission: Add multi-campaign agent actions that preview every affected record before bulk updates or deletions run.

Are there any prerequisites?

You only need basic familiarity with editing files plus running PowerShell commands. All application code and setup steps are included.

Before We Start

This first checkpoint defines what Campaign Copilot should achieve before any hands-on work begins. A clear goal keeps every later choice focused on useful marketing work.

Prepare the Full-Stack Workspace

Campaign Copilot connects a browser dashboard to a server. Every later feature depends on a stable local foundation.

A verified Node.js runtime prevents version drift. Pinned npm dependencies keep the project consistent across installs.

The Gemini Developer API key belongs in a Windows environment variable. This keeps the credential outside your client code.

In this step, get ready to:
  • Verify Node.js plus npm versions. Confirm that Microsoft Edge opens.
  • Create the project folder in Visual Studio Code. Install the pinned dependencies.
  • Create or view a Gemini API key. Store it in the Windows user environment.
Verify Node.js and Microsoft Edge

Vite requires a compatible Node.js runtime. This project pins Node.js v24.22.1 LTS to remove that source of variation.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Select Windows PowerShell from the search results.
  • Check the installed Node.js plus npm versions by running these commands:
node --version
npm --version

What do these checks prove?

  • The first command confirms which Node.js runtime PowerShell can access.
  • The second command confirms that npm is available for dependency installation.

✔️ I see the required version

You should see v24.22.1 for Node.js. npm should print its installed version number.

Your JavaScript runtime is ready for the pinned Vite packages.

ⓧ I see an older version

Your installed runtime is below the pinned target. Upgrade it before installing the project packages.

  • Visit the official Node.js download page.
  • Download the Windows installer for Node.js v24.22.1 LTS.
  • Run the downloaded installer.
  • Follow the installer prompts to upgrade Node.js.
  • Close the current PowerShell window.
  • Open a new PowerShell window through Windows search.
  • Run the version checks again. Confirm that Node.js now prints v24.22.1.

ⓧ Command not found

Windows cannot find Node.js yet. Install the pinned runtime before continuing.

  • Visit the official Node.js download page.
  • Download the Windows installer for Node.js v24.22.1 LTS.
  • Run the downloaded installer.
  • Follow the installer prompts to install Node.js plus npm.
  • Close the current PowerShell window.
  • Open a new PowerShell window through Windows search.
  • Run the version checks again. Confirm that both commands print version numbers.

The dashboard needs a browser for its visible checks. Windows search reveals whether Microsoft Edge is ready.

  • Press the Windows key to open Windows search.
  • Type Microsoft Edge into the search field.
  • Choose the tab below that matches your search result.

✔️ Microsoft Edge appears

  • Select Microsoft Edge from the search results.

You should see a Microsoft Edge window open. Your browser is ready for the dashboard checks.

ⓧ Microsoft Edge is missing

  • Visit the official Microsoft Edge download page.
  • Download Microsoft Edge for Windows.
  • Run the downloaded installer.
  • Follow the installer prompts to complete the installation.
  • Search Windows for Microsoft Edge again.
  • Select Microsoft Edge from the search results.

You should see the browser window open after installation.

Create the workspace and install dependencies

The campaign-copilot folder keeps every frontend plus backend file together. Its package.json file pins the exact tools used throughout the project.

  • Switch back to the PowerShell window from earlier.
  • Create the campaign-copilot folder on your Desktop by running these commands:
New-Item -Path "$env:USERPROFILE\Desktop\campaign-copilot" -ItemType Directory
Set-Location "$env:USERPROFILE\Desktop\campaign-copilot"

What do these commands do?

  • The first command creates the campaign-copilot folder on your Windows Desktop.
  • The second command moves PowerShell into that folder. Future project commands now run in the correct location.
  • Confirm that PowerShell reports the new campaign-copilot directory.
  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Select File from the top menu.
  • Select Open Folder....
  • Choose the campaign-copilot folder from your Desktop.
  • Select Select Folder.

About Workspace Trust

Visual Studio Code may show a Workspace Trust prompt after the folder opens. Select Yes, I trust the authors because you created this empty folder yourself.

You should see campaign-copilot at the top of the Explorer sidebar. That confirms Visual Studio Code has the correct workspace open.

  • Select the Explorer view in the left Activity Bar.
  • Select New File... in the Explorer toolbar.
  • Enter package.json as the file name.
  • Press Enter to create the file.
  • Define the pinned project scripts plus dependencies by pasting this code into package.json:
{
  "name": "campaign-copilot",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "server": "node server.js"
  },
  "dependencies": {
    "@google/genai": "2.31.0",
    "express": "5.3.0",
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.2",
    "vite": "8.3.5"
  }
}

What does this file control?

  • The scripts provide commands for the Vite development server. They also provide the production build plus Express server commands.
  • The dependencies pin React, Express, and the Google Gen AI SDK to the project versions.
  • The development dependencies pin Vite plus its React plugin.
  • Press Ctrl+S to save package.json.
  • Confirm that package.json appears under campaign-copilot in the Explorer sidebar.

Does package.json show a problem?

Check every comma plus quotation mark against the reference below. JSON syntax breaks when one delimiter is missing.

Ask for targeted help with my package.json syntax problem.

✔️ Awesome, I've got everything!

Great. Double-check that package.json is saved before installing the dependencies.

ⓧ I'd like to double check the full code

{
  "name": "campaign-copilot",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "server": "node server.js"
  },
  "dependencies": {
    "@google/genai": "2.31.0",
    "express": "5.3.0",
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.2",
    "vite": "8.3.5"
  }
}
  • Select View from the Visual Studio Code menu.
  • Select Terminal to open the integrated terminal.
  • Install the dependencies defined in package.json by running this command:
npm install

What does this command do?

npm reads package.json and downloads each pinned dependency. This gives the frontend plus server the exact packages they need.

  • Wait for the PowerShell prompt to return.
  • Confirm that npm finishes without dependency errors.
  • Confirm that the Explorer sidebar now lists node_modules plus package-lock.json.

Did npm install fail?

Confirm that the terminal path ends with campaign-copilot. Confirm that package.json matches the full reference above.

Ask for help diagnosing my npm installation error.

Protect the Gemini API key

A live API key can feel risky because it grants access to your Gemini project. Windows stores it outside the React files that later run in the browser.

  • Switch back to Microsoft Edge from earlier.
  • Navigate to Google AI Studio.
  • Sign in with your Google account if prompted.
  • Accept the terms if Google AI Studio requests them.
  • Select Dashboard.
  • Select API Keys.
  • Create or view an API key in the listed Google Cloud project.
  • Copy the API key to your clipboard.

Why keep the key on the server?

Browser code can be inspected by anyone who loads the page. The Express server reads GEMINI_API_KEY from its environment without sending the credential to React.

Use only learning data with the free tier. Free-tier prompts plus responses may be used to improve Google products.

  • Close the key dialog after copying the credential.
  • Press the Windows key to open Windows search.
  • Type Edit environment variables for your account into the search field.
  • Select Edit environment variables for your account from the search results.
  • Select New... under your user variables.
  • Enter GEMINI_API_KEY in the Variable name field.
  • Paste the copied API key into the Variable value field.
  • Select OK to save the variable.

Windows gives new processes a fresh copy of your environment variables. A newly opened Visual Studio Code terminal can access the key without revealing its value.

  • Select OK in the remaining Environment Variables window.
  • Return to Visual Studio Code.
  • Close the current integrated terminal.
  • Select View from the Visual Studio Code menu.
  • Select Terminal to open a new integrated terminal.

Before you run the final checks, do you expect every command to confirm the same workspace state you just configured?

  • Verify the complete workspace by running these commands in the new terminal:
node --version
npm --version
npm install
Test-Path Env:GEMINI_API_KEY

What should you see?

  • The first command should print v24.22.1.
  • The second command should print the installed npm version.
  • The third command should finish without dependency errors.
  • The final command should print True without displaying your API key.
  • Switch back to Microsoft Edge from earlier.

You should see the Microsoft Edge browser window open. The runtime, packages, browser, editor, and server-side credential are ready.

Did a final check fail?

If the Node.js version differs, complete the matching runtime tab above. Open a fresh terminal after the installation.

If the environment check returns False, confirm the variable name is exactly GEMINI_API_KEY. Fully restart Visual Studio Code before checking again.

Ask for help with my final workspace verification.

Your full-stack workspace is ready to build. Next, you will turn this empty project into a visible React campaign dashboard.

Build the React Dashboard

Your workspace is ready. Now you can turn the empty project into a visible React campaign dashboard that responds to your actions.

This first version keeps mock campaign data in browser state while you shape the interface. At the end, you will test whether a refresh preserves a campaign change.

In this step, get ready to:
  • Connect the HTML entry point to React through Vite.
  • Build campaign metrics, cards, controls, and a controlled form.
  • Style the responsive dashboard and test client-only state.
Wire the Vite entry files

The browser needs an HTML entry point before Vite can load your React module. A small configuration file also prepares the future API path without changing today's client-only dashboard.

  • Create index.html inside the campaign-copilot folder through the Visual Studio Code Explorer sidebar.
  • Add the document structure by pasting this code into index.html:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Campaign Copilot</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

What Does This File Do?

  • The root element gives React one place to render the dashboard.
  • The module script loads src/main.jsx as the JavaScript entry point.
  • The viewport setting lets the layout follow the width of phones and desktop screens.
  • Save index.html.
  • Confirm that index.html is listed beneath campaign-copilot in the Explorer sidebar.

Cannot Find the HTML File?

Check that index.html sits directly inside campaign-copilot. A nested copy cannot act as this project's Vite entry point.

Ask for help locating the file in your workspace.

  • Create vite.config.js inside campaign-copilot through the Explorer sidebar.
  • Configure the React plugin and API proxy by pasting this code into vite.config.js:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: { proxy: { "/api": "http://localhost:3001" } }
});

What Does This Configuration Do?

  • The React plugin lets Vite process the JSX used by your dashboard.
  • The proxy reserves requests beginning with /api for the Express server you will build later.
  • The browser can eventually use relative API paths without hard-coding a separate backend address.
  • Save vite.config.js.
  • Confirm that vite.config.js appears beside index.html in the Explorer sidebar.

Vite Configuration Not Recognized?

Check that the filename ends with .js. Make sure the file is beside package.json.

Ask for help checking the Vite configuration.

  • Create a src folder inside campaign-copilot through the Explorer sidebar.
  • Create main.jsx inside the new src folder.
  • Connect React to the HTML root by pasting this code into src/main.jsx:
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";

createRoot(document.getElementById("root")).render(<App />);

How Does React Reach the Page?

  • The createRoot call connects React to the root element from index.html.
  • The App component supplies the dashboard interface.
  • The stylesheet import applies the visual design to every rendered section.
  • Save src/main.jsx.
  • Confirm that main.jsx appears beneath src in the Explorer sidebar.

Main Entry File in the Wrong Place?

Check that the Explorer path reads src/main.jsx. A copy beside package.json does not match the script path in index.html.

Ask for help checking the entry-file path.

Build the client-only dashboard

A controlled form stores each input value in React state. The same state also drives the campaign cards and recalculates the metrics after each local change.

  • Create App.jsx inside the src folder through the Explorer sidebar.
  • Build the client-only dashboard by pasting this code into src/App.jsx:
import { useState } from "react";

const emptyForm = { name: "", audience: "", budget: 1000, status: "Draft" };
const initialCampaigns = [ // Supplies visible data before the API exists.
  { id: 1, name: "Spring Launch", audience: "Remote professionals", budget: 4800, status: "Active", clicks: 1240, conversions: 86 },
  { id: 2, name: "Creator Retargeting", audience: "Trial users", budget: 2600, status: "Paused", clicks: 760, conversions: 41 },
  { id: 3, name: "Partner Newsletter", audience: "Agency partners", budget: 1400, status: "Draft", clicks: 0, conversions: 0 }
];
const aiActions = [["copy", "Generate campaign copy"], ["summary", "Summarize performance"], ["improvements", "Suggest improvements"], ["create", "Create from brief"]];

export default function App() {
  const [campaigns, setCampaigns] = useState(initialCampaigns); // Keeps this temporary collection in browser memory.
  const [form, setForm] = useState(emptyForm);
  const [editingId, setEditingId] = useState(null);
  const [message, setMessage] = useState(""); const [aiAction, setAiAction] = useState("copy"); const [brief, setBrief] = useState("Launch a productivity app for remote creative teams.");
  const totals = campaigns.reduce((sum, item) => ({ budget: sum.budget + item.budget, clicks: sum.clicks + item.clicks, conversions: sum.conversions + item.conversions }), { budget: 0, clicks: 0, conversions: 0 }); // Recalculates the metric cards.
  function editCampaign(item) { setEditingId(item.id); setForm({ name: item.name, audience: item.audience, budget: item.budget, status: item.status }); }
  function saveCampaign(event) { event.preventDefault(); const data = { ...form, budget: Number(form.budget) }; if (editingId) setCampaigns((current) => current.map((item) => item.id === editingId ? { ...item, ...data } : item)); else setCampaigns((current) => [...current, { id: Date.now(), ...data, clicks: 0, conversions: 0 }]); setForm(emptyForm); setEditingId(null); setMessage("Campaign saved."); }
  function deleteCampaign(item) { if (!window.confirm(`Delete ${item.name}?`)) return; setCampaigns((current) => current.filter((campaign) => campaign.id !== item.id)); setMessage("Campaign deleted."); }
  return (
    <main className="shell">
      {/* Introduces the workspace and its demo state. */}<header><div><p className="eyebrow">Marketing workspace</p><h1>Campaign Copilot</h1><p>Full-stack CRUD with a focused AI assistant.</p></div><span className="pill">Demo ready</span></header>
      {/* Summarizes the current campaign collection. */}<section className="metrics"><article><span>Active</span><strong>{campaigns.filter((item) => item.status === "Active").length}</strong></article><article><span>Budget</span><strong>${totals.budget.toLocaleString()}</strong></article><article><span>Clicks</span><strong>{totals.clicks.toLocaleString()}</strong></article><article><span>Conversions</span><strong>{totals.conversions.toLocaleString()}</strong></article></section>
      {message && <p className="message success">{message}</p>}
      <section className="workspace">{/* Renders the mock records and their local controls. */}<div className="panel"><div className="heading"><h2>Campaigns</h2><span>{campaigns.length} total</span></div>{campaigns.map((item) => <article className="campaign" key={item.id}><div><span className={`status ${item.status.toLowerCase()}`}>{item.status}</span><h3>{item.name}</h3><p>{item.audience}</p></div><div className="numbers"><strong>${item.budget.toLocaleString()}</strong><span>{item.clicks} clicks · {item.conversions} conversions</span></div><div className="actions"><button onClick={() => editCampaign(item)}>Edit</button><button className="danger" onClick={() => deleteCampaign(item)}>Delete</button></div></article>)}</div>
        {/* Keeps every form field synchronized with React state. */}<form className="panel form" onSubmit={saveCampaign}><h2>{editingId ? "Edit campaign" : "New campaign"}</h2><label>Name<input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></label><label>Audience<input required value={form.audience} onChange={(event) => setForm({ ...form, audience: event.target.value })} /></label><label>Budget<input required min="0" type="number" value={form.budget} onChange={(event) => setForm({ ...form, budget: event.target.value })} /></label><label>Status<select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}><option>Draft</option><option>Active</option><option>Paused</option></select></label><button className="primary" type="submit">{editingId ? "Save changes" : "Create campaign"}</button></form>
      </section>
      {/* Reserves the interface for the later server-side AI action. */}<section className="panel ai"><div className="heading"><h2>AI campaign copilot</h2><span className="pill">Gemini</span></div><form className="ai-form" onSubmit={(event) => event.preventDefault()}><label>Action<select value={aiAction} onChange={(event) => setAiAction(event.target.value)}>{aiActions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>Campaign brief<textarea rows="4" value={brief} onChange={(event) => setBrief(event.target.value)} /></label><button className="primary">Run AI action</button></form></section>
    </main>); }

How Does the Dashboard Work?

  • The mock collection gives you three campaign cards before a backend exists.
  • The form state keeps the inputs synchronized with the values React renders.
  • The save function creates a new local record or replaces the selected record.
  • The metrics are derived from the current collection, so they update with every campaign change.
  • Save src/App.jsx.
  • Select Terminal from the Visual Studio Code menu bar.
  • Select New Terminal.

Vite keeps this terminal occupied while it serves the dashboard. Leave the process running while you work in the browser.

Before you start Vite, what do you expect the browser to show when React reaches the new App component?

  • Start the development server by running this command:
npm run dev

What Does This Command Do?

The dev script starts Vite from your installed project dependencies. Vite prints the local address where the dashboard is available.

  • Open the local URL printed by Vite in Microsoft Edge.

You should see the Campaign Copilot heading, four metrics, three campaign cards, a campaign form, and an AI workspace. That is your first full dashboard running in the browser.

Dashboard Not Loading?

Check the terminal for the first file path named in the error. A missing App.jsx or styles.css file stops the imports in main.jsx.

Ask for help diagnosing the Vite error.

Style and test the dashboard

The dashboard structure works, but its hierarchy needs CSS to become scannable. The first styling layer establishes the page, header, metrics, and two-column workspace.

  • Create styles.css inside the src folder through the Explorer sidebar.
  • Add the page and layout styles by pasting this first chunk into src/styles.css:
:root { font-family: Inter, "Segoe UI", sans-serif; color: #172036; background: #f3f5fb; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top left, #e6e2ff, transparent 32%), #f3f5fb; }
button, input, select, textarea { font: inherit; }
button { border: 0; border-radius: 10px; padding: 10px 14px; cursor: pointer; font-weight: 750; }
button:disabled { opacity: .6; cursor: wait; }
.shell { width: min(1120px, calc(100% - 28px)); margin: auto; padding: 36px 0 60px; }
header, .heading, .campaign, .actions { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
h1 { margin: 2px 0 6px; font-size: clamp(2.3rem, 6vw, 4rem); letter-spacing: -.05em; }
h2, h3, p { margin-top: 0; }
.eyebrow { margin: 0; color: #6858ff; font-size: .75rem; font-weight: 850; letter-spacing: .12em; text-transform: uppercase; }
.pill, .status { display: inline-block; width: fit-content; border-radius: 999px; padding: 6px 10px; color: #fff; background: #172036; font-size: .78rem; font-weight: 800; }
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 24px 0 18px; }
.metrics article, .panel { border: 1px solid #e1e5ef; border-radius: 18px; background: #fff; box-shadow: 0 15px 35px #1d27420d; }
.metrics article { padding: 18px; }
.metrics span, .heading > span, .numbers span { display: block; color: #667085; font-size: .85rem; }
.metrics strong { display: block; margin-top: 8px; font-size: 1.55rem; }
.workspace { display: grid; grid-template-columns: 1.6fr 1fr; gap: 18px; align-items: start; }
.panel { padding: 22px; }

What Does This Styling Change?

  • The shell limits the content width while preserving space on small screens.
  • The metrics use four equal columns on wide screens.
  • The workspace gives the campaign list more room than the form.
  • Shared panel styles create consistent white cards across the page.
  • Save src/styles.css.
  • Return to the dashboard in Microsoft Edge.

You should see a pale purple page with white metric panels and a two-column workspace. The campaign list should occupy the wider column.

Page Still Looks Unstyled?

Check that src/main.jsx imports ./styles.css. Confirm that styles.css sits in the same src folder.

Ask for help tracing the stylesheet import.

  • Append the campaign and form styles to the bottom of src/styles.css by pasting this chunk:
.heading { margin-bottom: 16px; }
.campaign { align-items: flex-start; padding: 15px 0; border-top: 1px solid #edf0f5; }
.campaign h3 { margin: 8px 0 4px; }
.status { padding: 4px 8px; color: #475569; background: #e2e8f0; }
.status.active { color: #166534; background: #dcfce7; }
.status.paused { color: #92400e; background: #fef3c7; }
.numbers { text-align: right; }
.actions { justify-content: flex-end; }
.danger { color: #be123c; background: #fff1f2; }
.primary { color: #fff; background: #6858ff; }
.form, .ai-form { display: grid; gap: 13px; }
label { display: grid; gap: 6px; color: #344054; font-size: .86rem; font-weight: 750; }
input, select, textarea { width: 100%; border: 1px solid #d8ddea; border-radius: 10px; padding: 10px; background: #fff; }
input:focus, select:focus, textarea:focus { outline: 3px solid #6858ff22; border-color: #6858ff; }
.message { padding: 11px 14px; border-radius: 12px; font-weight: 700; }
.success { color: #166534; background: #dcfce7; }
.error { color: #9f1239; background: #ffe4e6; }

How Do the Controls Communicate State?

  • Status classes give active and paused campaigns distinct colors.
  • Primary and danger buttons separate constructive actions from deletion.
  • Focus styles show which form control currently receives keyboard input.
  • Message styles reserve clear colors for success and error feedback.
  • Save src/styles.css.
  • Return to the dashboard in Microsoft Edge.

You should see colored status pills, aligned campaign controls, purple primary buttons, and bordered form fields. The page now separates actions and states at a glance.

Status Colors Missing?

Check that the status values still read Active, Paused, or Draft. The component converts those values into the lowercase class names used by the stylesheet.

Ask for help comparing the status classes.

  • Append the AI result and responsive styles to the bottom of src/styles.css by pasting this final chunk:
.ai { margin-top: 18px; }
.ai-form { grid-template-columns: .8fr 2fr auto; align-items: end; }
.results { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 18px; }
.results article { padding: 15px; border-radius: 14px; background: #f6f7fb; }
.results ul { padding-left: 18px; }
@media (max-width: 850px) { .metrics, .results { grid-template-columns: repeat(2, 1fr); } .workspace, .ai-form { grid-template-columns: 1fr; } }
@media (max-width: 540px) { header, .campaign { align-items: flex-start; flex-direction: column; } .metrics, .results { grid-template-columns: 1fr; } .numbers { text-align: left; } }

How Does the Layout Respond?

  • The AI form uses three columns while enough horizontal space is available.
  • The first breakpoint changes the workspace and AI form to single-column layouts.
  • The smaller breakpoint stacks cards and metrics for narrow screens.
  • Save src/styles.css.
  • Narrow the Microsoft Edge window to test the mobile breakpoint.

You should see the metric cards, campaign workspace, and AI controls move into fewer columns. The dashboard remains readable without horizontal scrolling.

Mobile Layout Not Stacking?

Check that both media rules remain at the bottom of src/styles.css. Missing closing braces can prevent the browser from applying either breakpoint.

Ask for help checking the responsive CSS.

Use the reference tab if you want to compare every file before testing the dashboard state.

✔️ Awesome, I've got everything!

Great. Save every open file before you test the campaign controls.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Campaign Copilot</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

This file provides the browser document and React mount point.

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: { proxy: { "/api": "http://localhost:3001" } }
});

This file activates the React plugin and reserves the API proxy.

import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";

createRoot(document.getElementById("root")).render(<App />);

This file renders the dashboard and loads its stylesheet.

import { useState } from "react";

const emptyForm = { name: "", audience: "", budget: 1000, status: "Draft" };
const initialCampaigns = [ // Supplies visible data before the API exists.
  { id: 1, name: "Spring Launch", audience: "Remote professionals", budget: 4800, status: "Active", clicks: 1240, conversions: 86 },
  { id: 2, name: "Creator Retargeting", audience: "Trial users", budget: 2600, status: "Paused", clicks: 760, conversions: 41 },
  { id: 3, name: "Partner Newsletter", audience: "Agency partners", budget: 1400, status: "Draft", clicks: 0, conversions: 0 }
];
const aiActions = [["copy", "Generate campaign copy"], ["summary", "Summarize performance"], ["improvements", "Suggest improvements"], ["create", "Create from brief"]];

export default function App() {
  const [campaigns, setCampaigns] = useState(initialCampaigns); // Keeps this temporary collection in browser memory.
  const [form, setForm] = useState(emptyForm);
  const [editingId, setEditingId] = useState(null);
  const [message, setMessage] = useState(""); const [aiAction, setAiAction] = useState("copy"); const [brief, setBrief] = useState("Launch a productivity app for remote creative teams.");
  const totals = campaigns.reduce((sum, item) => ({ budget: sum.budget + item.budget, clicks: sum.clicks + item.clicks, conversions: sum.conversions + item.conversions }), { budget: 0, clicks: 0, conversions: 0 }); // Recalculates the metric cards.
  function editCampaign(item) { setEditingId(item.id); setForm({ name: item.name, audience: item.audience, budget: item.budget, status: item.status }); }
  function saveCampaign(event) { event.preventDefault(); const data = { ...form, budget: Number(form.budget) }; if (editingId) setCampaigns((current) => current.map((item) => item.id === editingId ? { ...item, ...data } : item)); else setCampaigns((current) => [...current, { id: Date.now(), ...data, clicks: 0, conversions: 0 }]); setForm(emptyForm); setEditingId(null); setMessage("Campaign saved."); }
  function deleteCampaign(item) { if (!window.confirm(`Delete ${item.name}?`)) return; setCampaigns((current) => current.filter((campaign) => campaign.id !== item.id)); setMessage("Campaign deleted."); }
  return (
    <main className="shell">
      {/* Introduces the workspace and its demo state. */}<header><div><p className="eyebrow">Marketing workspace</p><h1>Campaign Copilot</h1><p>Full-stack CRUD with a focused AI assistant.</p></div><span className="pill">Demo ready</span></header>
      {/* Summarizes the current campaign collection. */}<section className="metrics"><article><span>Active</span><strong>{campaigns.filter((item) => item.status === "Active").length}</strong></article><article><span>Budget</span><strong>${totals.budget.toLocaleString()}</strong></article><article><span>Clicks</span><strong>{totals.clicks.toLocaleString()}</strong></article><article><span>Conversions</span><strong>{totals.conversions.toLocaleString()}</strong></article></section>
      {message && <p className="message success">{message}</p>}
      <section className="workspace">{/* Renders the mock records and their local controls. */}<div className="panel"><div className="heading"><h2>Campaigns</h2><span>{campaigns.length} total</span></div>{campaigns.map((item) => <article className="campaign" key={item.id}><div><span className={`status ${item.status.toLowerCase()}`}>{item.status}</span><h3>{item.name}</h3><p>{item.audience}</p></div><div className="numbers"><strong>${item.budget.toLocaleString()}</strong><span>{item.clicks} clicks · {item.conversions} conversions</span></div><div className="actions"><button onClick={() => editCampaign(item)}>Edit</button><button className="danger" onClick={() => deleteCampaign(item)}>Delete</button></div></article>)}</div>
        {/* Keeps every form field synchronized with React state. */}<form className="panel form" onSubmit={saveCampaign}><h2>{editingId ? "Edit campaign" : "New campaign"}</h2><label>Name<input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></label><label>Audience<input required value={form.audience} onChange={(event) => setForm({ ...form, audience: event.target.value })} /></label><label>Budget<input required min="0" type="number" value={form.budget} onChange={(event) => setForm({ ...form, budget: event.target.value })} /></label><label>Status<select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}><option>Draft</option><option>Active</option><option>Paused</option></select></label><button className="primary" type="submit">{editingId ? "Save changes" : "Create campaign"}</button></form>
      </section>
      {/* Reserves the interface for the later server-side AI action. */}<section className="panel ai"><div className="heading"><h2>AI campaign copilot</h2><span className="pill">Gemini</span></div><form className="ai-form" onSubmit={(event) => event.preventDefault()}><label>Action<select value={aiAction} onChange={(event) => setAiAction(event.target.value)}>{aiActions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>Campaign brief<textarea rows="4" value={brief} onChange={(event) => setBrief(event.target.value)} /></label><button className="primary">Run AI action</button></form></section>
    </main>); }

This temporary component keeps campaign changes in browser memory while exposing the complete dashboard layout.

:root { font-family: Inter, "Segoe UI", sans-serif; color: #172036; background: #f3f5fb; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top left, #e6e2ff, transparent 32%), #f3f5fb; }
button, input, select, textarea { font: inherit; }
button { border: 0; border-radius: 10px; padding: 10px 14px; cursor: pointer; font-weight: 750; }
button:disabled { opacity: .6; cursor: wait; }
.shell { width: min(1120px, calc(100% - 28px)); margin: auto; padding: 36px 0 60px; }
header, .heading, .campaign, .actions { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
h1 { margin: 2px 0 6px; font-size: clamp(2.3rem, 6vw, 4rem); letter-spacing: -.05em; }
h2, h3, p { margin-top: 0; }
.eyebrow { margin: 0; color: #6858ff; font-size: .75rem; font-weight: 850; letter-spacing: .12em; text-transform: uppercase; }
.pill, .status { display: inline-block; width: fit-content; border-radius: 999px; padding: 6px 10px; color: #fff; background: #172036; font-size: .78rem; font-weight: 800; }
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 24px 0 18px; }
.metrics article, .panel { border: 1px solid #e1e5ef; border-radius: 18px; background: #fff; box-shadow: 0 15px 35px #1d27420d; }
.metrics article { padding: 18px; }
.metrics span, .heading > span, .numbers span { display: block; color: #667085; font-size: .85rem; }
.metrics strong { display: block; margin-top: 8px; font-size: 1.55rem; }
.workspace { display: grid; grid-template-columns: 1.6fr 1fr; gap: 18px; align-items: start; }
.panel { padding: 22px; }
.heading { margin-bottom: 16px; }
.campaign { align-items: flex-start; padding: 15px 0; border-top: 1px solid #edf0f5; }
.campaign h3 { margin: 8px 0 4px; }
.status { padding: 4px 8px; color: #475569; background: #e2e8f0; }
.status.active { color: #166534; background: #dcfce7; }
.status.paused { color: #92400e; background: #fef3c7; }
.numbers { text-align: right; }
.actions { justify-content: flex-end; }
.danger { color: #be123c; background: #fff1f2; }
.primary { color: #fff; background: #6858ff; }
.form, .ai-form { display: grid; gap: 13px; }
label { display: grid; gap: 6px; color: #344054; font-size: .86rem; font-weight: 750; }
input, select, textarea { width: 100%; border: 1px solid #d8ddea; border-radius: 10px; padding: 10px; background: #fff; }
input:focus, select:focus, textarea:focus { outline: 3px solid #6858ff22; border-color: #6858ff; }
.message { padding: 11px 14px; border-radius: 12px; font-weight: 700; }
.success { color: #166534; background: #dcfce7; }
.error { color: #9f1239; background: #ffe4e6; }
.ai { margin-top: 18px; }
.ai-form { grid-template-columns: .8fr 2fr auto; align-items: end; }
.results { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 18px; }
.results article { padding: 15px; border-radius: 14px; background: #f6f7fb; }
.results ul { padding-left: 18px; }
@media (max-width: 850px) { .metrics, .results { grid-template-columns: repeat(2, 1fr); } .workspace, .ai-form { grid-template-columns: 1fr; } }
@media (max-width: 540px) { header, .campaign { align-items: flex-start; flex-direction: column; } .metrics, .results { grid-template-columns: 1fr; } .numbers { text-align: left; } }

This stylesheet covers the page, campaign controls, AI workspace, and responsive breakpoints.

The interface is ready for its state test. Create a recognizable record so the effect of a browser refresh is easy to spot.

  • Enter Autumn Webinar in the Name field.
  • Enter Marketing managers in the Audience field.
  • Enter 1800 in the Budget field.
  • Select Active in the Status field.
  • Click Create campaign.

You should see an Autumn Webinar card and a success message. The campaign total, active count, and budget should increase immediately.

  • Click Edit on the Autumn Webinar card.
  • Replace the budget with 2200.
  • Click Save changes.

You should see the card budget change to $2,200. The total budget should increase by the same amount.

Before you refresh, do you think Autumn Webinar will still be listed when the page loads again?

  • Refresh the dashboard page in Microsoft Edge.

The dashboard should return with the original three mock campaigns. Autumn Webinar disappears because this version stored the change only in browser memory.

Why Did the Campaign Disappear?

React rebuilt the component from initialCampaigns when the page loaded again. The temporary state had no persistent server boundary.

This is the intended limitation of the client-only version. You now have visible evidence of the data problem the backend must solve.

Your responsive dashboard now renders campaign data and reacts to local CRUD controls. Next, you will move that disposable collection behind an Express API.

Create the Express REST API

Your dashboard now presents campaign data through cards, metrics, and a controlled form. A browser refresh still discards every change held in client-only React state.

This step moves campaign data into an Express server. Its REST routes give each CRUD action an explicit contract.

In this step, get ready to:
  • Start a separate campaign API with seeded in-memory data.
  • Validate campaign input through GET and POST routes.
  • Complete the PUT and DELETE routes for updates and removals.
Create the Express server

The API needs its own process because it owns the reusable campaign collection. An in-memory array keeps the focus on route contracts before a database enters the architecture.

  • Select the campaign-copilot folder in the VS Code Explorer sidebar.
  • Select the file creation icon at the top of the Explorer sidebar.
  • Type server.js into the filename field.
  • Press Enter.
  • Build the server foundation by pasting this code into server.js:
import express from "express";
import { GoogleGenAI } from "@google/genai";

const app = express();
const port = 3001;
app.use(express.json());

let nextId = 4;
let campaigns = [
  { id: 1, name: "Spring Launch", audience: "Remote professionals", budget: 4800, status: "Active", clicks: 1240, conversions: 86 },
  { id: 2, name: "Creator Retargeting", audience: "Trial users", budget: 2600, status: "Paused", clicks: 760, conversions: 41 },
  { id: 3, name: "Partner Newsletter", audience: "Agency partners", budget: 1400, status: "Draft", clicks: 0, conversions: 0 }
];

app.listen(port, () => console.log(`Campaign API running on http://localhost:${port}`));

What does this server foundation do?

  • The app object becomes the server where each campaign route lives.
  • The express.json() middleware parses JSON request bodies for validation.
  • The campaigns array starts with three records that the API can return immediately.
  • The Google Gen AI SDK import keeps the future AI integration inside the server.
  • The app.listen() call starts the API on port 3001.
  • Save server.js.
  • Click the plus icon in the VS Code terminal panel to create a new terminal for the API.

Before you run the server, predict whether the listener reaches port 3001.

  • Start the API process by running this command:
npm run server

You'll see Campaign API running on http://localhost:3001 in the terminal.

That first server start is a milestone. Your API process is listening for browser requests.

Server did not start?

Confirm that the terminal is inside the campaign-copilot folder. Check that server.js is saved beside package.json.

If the terminal reports an import problem, confirm that the dependency installation from the workspace step completed successfully.

Ask for help with the server startup output.

Validate and create campaigns

Request bodies can contain missing or malformed values. The normalizeCampaign() function creates one validation boundary for every stored campaign.

  • Press Ctrl+C in the server terminal to stop the API process.
  • In server.js, find the closing ]; below the seeded campaigns.
  • Add the validation function and read route below that line:
function normalizeCampaign(input = {}) {
  const name = typeof input.name === "string" ? input.name.trim() : "";
  const audience = typeof input.audience === "string" ? input.audience.trim() : "";
  const budget = Number(input.budget);
  const statuses = ["Draft", "Active", "Paused"];
  const status = statuses.includes(input.status) ? input.status : "Draft";
  if (!name || !audience || !Number.isFinite(budget) || budget < 0) return null;
  return { name, audience, budget, status };
}

app.get("/api/campaigns", (req, res) => res.json(campaigns));

How do validation and reading work?

  • The validator trims the campaign name and audience before storage.
  • The budget must become a finite non-negative number.
  • The status must match Draft, Active, or Paused.
  • The GET route returns the current in-memory collection as JSON.
  • Save server.js.
  • Restart the API by running this command:
npm run server

Before you request the route, predict how many seeded campaign objects the response contains.

  • Return to Microsoft Edge.
  • Enter http://localhost:3001/api/campaigns in the address bar.
  • Press Enter.

You'll see a JSON array containing Spring Launch, Creator Retargeting, and Partner Newsletter.

You have completed your first browser-to-server round trip. Express now owns the campaign collection returned by that URL.

Campaign JSON did not load?

Return to the server terminal and confirm that the API process is still running. Check that the route path in server.js matches the address in Microsoft Edge.

Ask for help if the browser cannot reach the campaign route.

Reading proves the server can expose its collection. Creating campaigns also requires a contract for accepted input and predictable validation errors.

  • Press Ctrl+C in the server terminal to stop the API process.
  • In server.js, find the GET route.
  • Add the create route directly below it:
app.post("/api/campaigns", (req, res) => {
  const data = normalizeCampaign(req.body ?? {});
  if (!data) return res.status(400).json({ error: "Valid name, audience, and budget are required." });
  const campaign = { id: nextId++, ...data, clicks: 0, conversions: 0 };
  campaigns.push(campaign);
  return res.status(201).json(campaign);
});

How does campaign creation work?

  • The route passes the request body through normalizeCampaign() before creating a record.
  • Invalid input receives a 400 response with a JSON error.
  • A valid campaign receives the next ID with zero clicks and zero conversions.
  • The successful response uses status 201 to return the created campaign.
  • Save server.js.
  • Restart the API by running this command:
npm run server

You'll see the listener message again. That output confirms the new route parses successfully when the server starts.

Server stopped after adding POST?

Check the closing braces and parentheses around app.post(). Confirm that the route remains above app.listen().

Ask for help with the create route syntax.

Complete the CRUD routes

Updates need both an existing campaign ID and valid replacement data. The update route checks those conditions separately so each failure returns a useful JSON response.

  • Press Ctrl+C in the server terminal to stop the API process.
  • In server.js, find the closing line of the POST route.
  • Add the update route directly below it:
app.put("/api/campaigns/:id", (req, res) => {
  const index = campaigns.findIndex((item) => item.id === Number(req.params.id));
  const data = normalizeCampaign(req.body ?? {});
  if (index === -1) return res.status(404).json({ error: "Campaign not found." });
  if (!data) return res.status(400).json({ error: "Valid name, audience, and budget are required." });
  campaigns[index] = { ...campaigns[index], ...data };
  return res.json(campaigns[index]);
});

How does the update route work?

  • The :id route parameter identifies the campaign to update.
  • A missing campaign receives a 404 JSON response.
  • Invalid replacement data receives the same 400 validation response used during creation.
  • A valid request replaces the editable fields while preserving existing metrics.
  • Save server.js.
  • Restart the API by running this command:
npm run server

You'll see the campaign API listener message. The updated server file has loaded without a syntax failure.

Update route prevents startup?

Confirm that req.params.id is inside the app.put() handler. Check that each error response closes with matching braces and parentheses.

Ask for help with the update route structure.

Deletion needs a clear response when an ID is missing. A successful removal filters the collection and ends with no response body.

  • Press Ctrl+C in the server terminal to stop the API process.
  • In server.js, find the closing line of the PUT route.
  • Add the delete route directly below it:
app.delete("/api/campaigns/:id", (req, res) => {
  const id = Number(req.params.id);
  if (!campaigns.some((item) => item.id === id)) return res.status(404).json({ error: "Campaign not found." });
  campaigns = campaigns.filter((item) => item.id !== id);
  return res.status(204).end();
});

How does deletion work?

  • The route converts the requested ID into a number.
  • A missing ID receives the same 404 campaign error used by the update route.
  • The filter keeps every campaign except the matching record.
  • A successful deletion ends with status 204.
  • Save server.js.
  • Start the completed API by running this command:
npm run server

Before you refresh the endpoint, predict which campaign names the fresh in-memory store contains.

  • Return to the campaign endpoint in Microsoft Edge.
  • Refresh the page.

You'll see the three seeded campaigns as JSON. This confirms that the completed Express server starts successfully and serves its collection from http://localhost:3001/api/campaigns.

Final campaign check failed?

Confirm that the terminal still shows the running API process. Check that app.listen() remains at the bottom of server.js.

Ask for help comparing the completed API file with the expected route order.

✔️ Awesome, I've got everything!

Your server.js file is saved. The seeded campaign endpoint now responds in Microsoft Edge.

ⓧ I'd like to double check the full code

import express from "express";
import { GoogleGenAI } from "@google/genai";

const app = express();
const port = 3001;
app.use(express.json());

let nextId = 4;
let campaigns = [
  { id: 1, name: "Spring Launch", audience: "Remote professionals", budget: 4800, status: "Active", clicks: 1240, conversions: 86 },
  { id: 2, name: "Creator Retargeting", audience: "Trial users", budget: 2600, status: "Paused", clicks: 760, conversions: 41 },
  { id: 3, name: "Partner Newsletter", audience: "Agency partners", budget: 1400, status: "Draft", clicks: 0, conversions: 0 }
];

function normalizeCampaign(input = {}) {
  const name = typeof input.name === "string" ? input.name.trim() : "";
  const audience = typeof input.audience === "string" ? input.audience.trim() : "";
  const budget = Number(input.budget);
  const statuses = ["Draft", "Active", "Paused"];
  const status = statuses.includes(input.status) ? input.status : "Draft";
  if (!name || !audience || !Number.isFinite(budget) || budget < 0) return null;
  return { name, audience, budget, status };
}

app.get("/api/campaigns", (req, res) => res.json(campaigns));

app.post("/api/campaigns", (req, res) => {
  const data = normalizeCampaign(req.body ?? {});
  if (!data) return res.status(400).json({ error: "Valid name, audience, and budget are required." });
  const campaign = { id: nextId++, ...data, clicks: 0, conversions: 0 };
  campaigns.push(campaign);
  return res.status(201).json(campaign);
});

app.put("/api/campaigns/:id", (req, res) => {
  const index = campaigns.findIndex((item) => item.id === Number(req.params.id));
  const data = normalizeCampaign(req.body ?? {});
  if (index === -1) return res.status(404).json({ error: "Campaign not found." });
  if (!data) return res.status(400).json({ error: "Valid name, audience, and budget are required." });
  campaigns[index] = { ...campaigns[index], ...data };
  return res.json(campaigns[index]);
});

app.delete("/api/campaigns/:id", (req, res) => {
  const id = Number(req.params.id);
  if (!campaigns.some((item) => item.id === id)) return res.status(404).json({ error: "Campaign not found." });
  campaigns = campaigns.filter((item) => item.id !== id);
  return res.status(204).end();
});

app.listen(port, () => console.log(`Campaign API running on http://localhost:${port}`));

Your API now owns the reusable campaign contracts while the dashboard still uses local data. Next, you'll connect the React interface to these routes so create, edit, refresh, and delete actions travel through Express.

Connect React CRUD to Express

Your Express API now owns the campaign collection. However, the React dashboard still changes temporary browser state.

This step replaces those local mutations with REST requests through the Fetch API. You will see each browser action reach the server before the interface reloads the latest collection.

In this step, get ready to:
  • Load campaigns from Express through the existing Vite proxy.
  • Send form submissions to the POST or PUT REST routes.
  • Delete campaigns through Express and verify that changes survive a browser refresh.
Load campaigns from Express

A browser refresh preserves campaign changes when the source of truth lives outside React state. Express now fills that role while Vite forwards each request beginning with /api.

  • Return to the VS Code terminal from earlier.
  • Start the Express API by running this command:
npm run server

What Does This Command Do?

The package script runs server.js. Express then listens for campaign requests on port 3001.

You should see Campaign API running on http://localhost:3001 in the terminal. Keep this process running.

Express Did Not Start?

Check that the terminal is inside the campaign-copilot folder. Also confirm that another process is not already using port 3001.

Help me diagnose why the Campaign Copilot Express server will not start on port 3001.

  • Create a second terminal with the plus button in the VS Code terminal panel.
  • Start the Vite development server in the second terminal by running this command:
npm run dev

What Does This Command Do?

The development script starts Vite for the React interface. The existing proxy forwards browser requests from /api to Express.

You should see a local URL in the second terminal. Keep this process running beside Express.

  • Return to Microsoft Edge from earlier.
  • Navigate to http://localhost:5173 in the address bar.
  • In the VS Code Explorer sidebar, select src/App.jsx.
  • Replace everything above export default function App() with this code:
import { useEffect, useState } from "react";

const emptyForm = { name: "", audience: "", budget: 1000, status: "Draft" };
const aiActions = [
  ["copy", "Generate campaign copy"],
  ["summary", "Summarize performance"],
  ["improvements", "Suggest improvements"],
  ["create", "Create from brief"]
];

async function api(path, options = {}) {
  const response = await fetch(path, {
    ...options,
    headers: { "Content-Type": "application/json", ...(options.headers ?? {}) }
  });
  if (response.status === 204) return null;
  const data = await response.json();
  if (!response.ok) throw new Error(data.error ?? "Request failed.");
  return data;
}

What Does This Code Do?

  • The useEffect import lets the component synchronize with the server after its first render.
  • The api() helper sends requests through one shared path.
  • The helper converts successful responses into JavaScript data.
  • The helper turns a JSON error response into an error the interface can display.
  • Inside App(), replace the existing state declarations and totals calculation with this code:
  const [campaigns, setCampaigns] = useState([]);
  const [form, setForm] = useState(emptyForm);
  const [editingId, setEditingId] = useState(null);
  const [loading, setLoading] = useState(true);
  const [message, setMessage] = useState("");
  const [isError, setIsError] = useState(false);
  const [aiAction, setAiAction] = useState("copy");
  const [brief, setBrief] = useState("Launch a productivity app for remote creative teams.");
  const [aiResult, setAiResult] = useState(null);
  const [aiLoading, setAiLoading] = useState(false);

  async function loadCampaigns() {
    setLoading(true);
    try {
      setCampaigns(await api("/api/campaigns"));
      setIsError(false);
    } catch (error) {
      setMessage(error.message);
      setIsError(true);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => { loadCampaigns(); }, []);

  const totals = campaigns.reduce(
    (sum, item) => ({ budget: sum.budget + item.budget, clicks: sum.clicks + item.clicks, conversions: sum.conversions + item.conversions }),
    { budget: 0, clicks: 0, conversions: 0 }
  );

How Does Loading Work?

  • The empty campaigns array prevents old mock data from appearing before the request finishes.
  • The loadCampaigns() function requests the collection from /api/campaigns.
  • The useEffect() call loads the collection when the dashboard starts.
  • The totals calculation now uses the campaigns returned by Express.
  • Save src/App.jsx by pressing Ctrl+S.
  • Return to Microsoft Edge.

You should briefly see Loading campaigns.... The dashboard should then show Spring Launch, Creator Retargeting, and Partner Newsletter.

The connection is working. Your dashboard is now reading the collection owned by Express.

Campaigns Are Not Loading?

Confirm that both terminal processes are still running. Check that vite.config.js still forwards /api requests to port 3001.

Help me debug why my React dashboard cannot load campaigns from my Express API.

Save campaigns through REST

Editing starts by copying one server record into the controlled form. The record ID tells the save handler whether the next request creates a campaign or updates one.

  • In src/App.jsx, replace the existing campaign edit handler with this code:
  function editCampaign(item) {
    setEditingId(item.id);
    setForm({ name: item.name, audience: item.audience, budget: item.budget, status: item.status });
  }

What Does This Handler Do?

The handler stores the selected campaign ID in editingId. It also fills the controlled form with that campaign's current values.

  • Save src/App.jsx.
  • Click Edit on the Spring Launch card.

The form should switch to Edit campaign. Its fields should contain the Spring Launch values.

Form Values Did Not Change?

Check that the card button still calls editCampaign(item). Confirm that the form inputs still read their values from form.

Help me debug why editCampaign does not populate my controlled React form.

  • Replace the existing campaign save handler in src/App.jsx with this code:
  async function saveCampaign(event) {
    event.preventDefault();
    try {
      await api(editingId ? `/api/campaigns/${editingId}` : "/api/campaigns", {
        method: editingId ? "PUT" : "POST",
        body: JSON.stringify({ ...form, budget: Number(form.budget) })
      });
      setForm(emptyForm);
      setEditingId(null);
      setMessage("Campaign saved.");
      setIsError(false);
      await loadCampaigns();
    } catch (error) {
      setMessage(error.message);
      setIsError(true);
    }
  }

How Does Saving Work?

  • An empty editingId sends a POST request to create a campaign.
  • A stored editingId sends a PUT request to update that campaign.
  • The form budget becomes a number before the request reaches Express validation.
  • The final loadCampaigns() call replaces browser state with the latest server collection.
  • Save src/App.jsx.
  • Return to Microsoft Edge.
  • Enter API Launch in the Name field.
  • Enter Remote designers in the Audience field.
  • Enter 1800 in the Budget field.
  • Select Active in the Status field.
  • Click Create campaign.

You should see an API Launch card. The dashboard should also show Campaign saved. above the workspace.

  • Click Edit on the API Launch card.
  • Change the Budget value to 2200.
  • Click Save changes.

The API Launch card should now show a budget of $2,200. The total budget metric should update from the returned server collection.

Campaign Did Not Save?

Make sure the name field contains text. Confirm that the audience field contains text.

Check the Express terminal for the request. A server validation failure should also appear in the dashboard message area.

Help me debug why my React form cannot create or update a campaign through Express.

Delete campaigns and verify persistence

Deleting follows the same server-first pattern. The interface waits for Express to remove the record before it reloads the collection.

  • Replace the existing campaign delete handler in src/App.jsx with this code:
  async function deleteCampaign(item) {
    if (!window.confirm(`Delete ${item.name}?`)) return;
    try {
      await api(`/api/campaigns/${item.id}`, { method: "DELETE" });
      setMessage("Campaign deleted.");
      setIsError(false);
      await loadCampaigns();
    } catch (error) {
      setMessage(error.message);
      setIsError(true);
    }
  }

How Does Deletion Work?

  • The browser asks for confirmation before sending the request.
  • The campaign ID becomes part of the request path.
  • A successful DELETE response has no JSON body.
  • The handler reloads the remaining campaigns from Express.

✔️ Awesome, I've got everything!

Your src/App.jsx file now connects the campaign interface to the REST API.

ⓧ I'd like to double check the full code

import { useEffect, useState } from "react";

const emptyForm = { name: "", audience: "", budget: 1000, status: "Draft" };
const aiActions = [
  ["copy", "Generate campaign copy"],
  ["summary", "Summarize performance"],
  ["improvements", "Suggest improvements"],
  ["create", "Create from brief"]
];

async function api(path, options = {}) {
  const response = await fetch(path, {
    ...options,
    headers: { "Content-Type": "application/json", ...(options.headers ?? {}) }
  });
  if (response.status === 204) return null;
  const data = await response.json();
  if (!response.ok) throw new Error(data.error ?? "Request failed.");
  return data;
}

export default function App() {
  const [campaigns, setCampaigns] = useState([]);
  const [form, setForm] = useState(emptyForm);
  const [editingId, setEditingId] = useState(null);
  const [loading, setLoading] = useState(true);
  const [message, setMessage] = useState("");
  const [isError, setIsError] = useState(false);
  const [aiAction, setAiAction] = useState("copy");
  const [brief, setBrief] = useState("Launch a productivity app for remote creative teams.");
  const [aiResult, setAiResult] = useState(null);
  const [aiLoading, setAiLoading] = useState(false);

  async function loadCampaigns() {
    setLoading(true);
    try {
      setCampaigns(await api("/api/campaigns"));
      setIsError(false);
    } catch (error) {
      setMessage(error.message);
      setIsError(true);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => { loadCampaigns(); }, []);

  const totals = campaigns.reduce(
    (sum, item) => ({ budget: sum.budget + item.budget, clicks: sum.clicks + item.clicks, conversions: sum.conversions + item.conversions }),
    { budget: 0, clicks: 0, conversions: 0 }
  );

  function editCampaign(item) {
    setEditingId(item.id);
    setForm({ name: item.name, audience: item.audience, budget: item.budget, status: item.status });
  }

  async function saveCampaign(event) {
    event.preventDefault();
    try {
      await api(editingId ? `/api/campaigns/${editingId}` : "/api/campaigns", {
        method: editingId ? "PUT" : "POST",
        body: JSON.stringify({ ...form, budget: Number(form.budget) })
      });
      setForm(emptyForm);
      setEditingId(null);
      setMessage("Campaign saved.");
      setIsError(false);
      await loadCampaigns();
    } catch (error) {
      setMessage(error.message);
      setIsError(true);
    }
  }

  async function deleteCampaign(item) {
    if (!window.confirm(`Delete ${item.name}?`)) return;
    try {
      await api(`/api/campaigns/${item.id}`, { method: "DELETE" });
      setMessage("Campaign deleted.");
      setIsError(false);
      await loadCampaigns();
    } catch (error) {
      setMessage(error.message);
      setIsError(true);
    }
  }

  return (
    <main className="shell">
      <header><div><p className="eyebrow">Marketing workspace</p><h1>Campaign Copilot</h1><p>Full-stack CRUD with a focused AI assistant.</p></div><span className="pill">Demo ready</span></header>

      <section className="metrics">
        <article><span>Active</span><strong>{campaigns.filter((item) => item.status === "Active").length}</strong></article>
        <article><span>Budget</span><strong>${totals.budget.toLocaleString()}</strong></article>
        <article><span>Clicks</span><strong>{totals.clicks.toLocaleString()}</strong></article>
        <article><span>Conversions</span><strong>{totals.conversions.toLocaleString()}</strong></article>
      </section>

      {message && <p className={isError ? "message error" : "message success"}>{message}</p>}

      <section className="workspace">
        <div className="panel">
          <div className="heading"><h2>Campaigns</h2><span>{campaigns.length} total</span></div>
          {loading ? <p>Loading campaigns...</p> : campaigns.map((item) => (
            <article className="campaign" key={item.id}>
              <div><span className={`status ${item.status.toLowerCase()}`}>{item.status}</span><h3>{item.name}</h3><p>{item.audience}</p></div>
              <div className="numbers"><strong>${item.budget.toLocaleString()}</strong><span>{item.clicks} clicks · {item.conversions} conversions</span></div>
              <div className="actions"><button onClick={() => editCampaign(item)}>Edit</button><button className="danger" onClick={() => deleteCampaign(item)}>Delete</button></div>
            </article>
          ))}
        </div>

        <form className="panel form" onSubmit={saveCampaign}>
          <h2>{editingId ? "Edit campaign" : "New campaign"}</h2>
          <label>Name<input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></label>
          <label>Audience<input required value={form.audience} onChange={(event) => setForm({ ...form, audience: event.target.value })} /></label>
          <label>Budget<input required min="0" type="number" value={form.budget} onChange={(event) => setForm({ ...form, budget: event.target.value })} /></label>
          <label>Status<select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}><option>Draft</option><option>Active</option><option>Paused</option></select></label>
          <button className="primary" type="submit">{editingId ? "Save changes" : "Create campaign"}</button>
        </form>
      </section>

      <section className="panel ai">
        <div className="heading"><h2>AI campaign copilot</h2><span className="pill">Gemini</span></div>
        <label>Action<select value={aiAction} onChange={(event) => setAiAction(event.target.value)}>{aiActions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
        <label>Campaign brief<textarea rows="4" value={brief} onChange={(event) => setBrief(event.target.value)} /></label>
        <button className="primary" disabled={aiLoading}>{aiLoading ? "Thinking..." : "Run AI action"}</button>
      </section>
    </main>
  );
}
  • Save src/App.jsx.

Before you test the full loop, do you expect the updated API Launch campaign to survive a browser refresh?

  • Refresh Microsoft Edge.

API Launch should remain visible with its $2,200 budget. Express still holds the updated record in memory.

  • Click Delete on the API Launch card.
  • Confirm the deletion in the browser dialog.

The API Launch card should disappear. You should also see Campaign deleted. above the workspace.

  • Refresh Microsoft Edge again.

API Launch should remain absent. The three seeded campaigns should still appear because Express returned its latest collection.

That completes the full CRUD loop. Campaigns now travel between the browser and server through observable REST requests.

Deletion Did Not Persist?

Confirm that the delete handler awaits the request before calling loadCampaigns(). Check that Express is still running in the first terminal.

Help me debug why a deleted campaign returns after refreshing my React dashboard.

Your dashboard now treats Express as its source of truth. Next, you will connect the server-side Gemini integration to the waiting AI interface.

Add the Gemini Campaign Copilot

Your React dashboard now sends every CRUD action through Express. Campaign records survive browser refreshes while the server keeps running.

CRUD manages campaign records. A server-side Gemini Developer API endpoint now adds campaign content without exposing the key to browser code.

In this step, get ready to:
  • Define predictable output for campaign copy and performance analysis.
  • Create a secure server endpoint that calls Gemini with the current campaign context.
  • Connect the React form to copy and analysis result cards.
Define the structured AI contract

A structured output schema gives every AI action the same predictable result shape. The interface can render those fields without guessing how each response is formatted.

  • Return to server.js in Visual Studio Code.
  • Locate the closing line of the app.delete("/api/campaigns/:id", (req, res) => { route.
  • Add the structured result schema immediately below that route:
const aiSchema = {
  type: "object",
  properties: {
    title: { type: "string" },
    summary: { type: "string" },
    copy: {
      type: "object",
      properties: { headline: { type: "string" }, body: { type: "string" }, cta: { type: "string" } },
      required: ["headline", "body", "cta"]
    },
    improvements: { type: "array", items: { type: "string" } },
    campaignDraft: {
      type: "object",
      properties: {
        name: { type: "string" },
        audience: { type: "string" },
        budget: { type: "integer", minimum: 0 },
        status: { type: "string", enum: ["Draft", "Active", "Paused"] }
      },
      required: ["name", "audience", "budget", "status"]
    }
  },
  required: ["title", "summary", "copy", "improvements", "campaignDraft"]
};

What does this schema define?

  • The top-level fields give each response a title plus a summary.
  • The copy object holds a headline plus body text plus a call to action.
  • The improvements array holds practical recommendations.
  • The campaignDraft object matches the values used by the campaign form.

The action map gives Gemini one focused read-only goal at a time. Copy generation uses the campaign brief, while performance analysis uses the current campaign collection.

  • Add the action map directly below aiSchema in server.js.
const actions = {
  copy: "Generate persuasive campaign copy from the brief.",
  analyze: "Analyze campaign performance and identify the strongest next step."
};

Why use two read-only actions?

The interface sends copy or analyze. The map expands that short value into a focused instruction without giving either action permission to change campaign data.

  • Save server.js.
  • Switch back to the Express terminal from earlier.
  • Press Ctrl+C to stop the current server process.
  • Restart Express with the new schema by running:
npm run server

What does this restart prove?

Node.js reads the updated file from the beginning. A successful restart confirms that the schema plus action map have valid syntax.

You should see Campaign API running on http://localhost:3001. Your existing campaign routes remain available.

Server not restarting?

  • Check that every property in aiSchema is separated by a comma.
  • Compare the closing braces around copy plus campaignDraft with the reference below.

Ask for help with the exact terminal output: help me find the syntax problem in my aiSchema or actions object

Create the secure AI endpoint

The endpoint keeps GEMINI_API_KEY inside the server process. Browser requests contain the selected action plus campaign context, while the Google Gen AI SDK handles the secret-bearing API call.

  • Return to server.js.
  • Locate the closing line of the actions object.
  • Add the AI route immediately below actions.
app.post("/api/ai", async (req, res) => {
  const body = req.body ?? {};
  const action = typeof body.action === "string" ? body.action : "";
  const brief = typeof body.brief === "string" ? body.brief.trim() : "";
  const context = Array.isArray(body.campaigns) ? body.campaigns : [];
  if (!actions[action]) return res.status(400).json({ error: "Choose a valid AI action." });
  if (!process.env.GEMINI_API_KEY) return res.status(503).json({ error: "GEMINI_API_KEY is missing." });

  try {
    const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
    const response = await ai.models.generateContent({
      model: "gemini-2.5-flash",
      contents: `You are a campaign copilot. ${actions[action]} Brief: ${brief || "None"}. Campaigns: ${JSON.stringify(context)}. Fill every schema field concisely.`,
      config: { responseMimeType: "application/json", responseSchema: aiSchema }
    });
    return res.json(JSON.parse(response.text));
  } catch (error) {
    console.error(error);
    return res.status(500).json({ error: "AI request failed. Check the server terminal and key." });
  }
});

What does this endpoint do?

  • The request accepts only the copy and analyze actions.
  • The server stops the request when GEMINI_API_KEY is unavailable.
  • The responseMimeType and responseSchema settings ask gemini-2.5-flash for predictable JSON.
  • The server parses response.text before returning the result to React.
  • Save server.js.
  • Switch back to the Express terminal.
  • Press Ctrl+C to stop the earlier server process.
  • Start the completed server by running:
npm run server

Why restart again?

The running Node.js process still holds the earlier version of server.js. Restarting loads POST /api/ai plus the environment variable available to the terminal.

You should see the campaign API address again. This confirms that Express loaded the completed endpoint without a syntax error.

AI route stopping the server?

  • Check that import { GoogleGenAI } from "@google/genai"; remains at the top of server.js.
  • Check that the AI route appears after aiSchema plus actions.

Share the terminal error without sharing your API key: help me debug why my Express AI route does not start

✔️ My server is running

Your CRUD routes plus AI route are loaded. Keep the Express terminal running for the browser test.

ⓧ I'd like to double check the full code

import express from "express";
import { GoogleGenAI } from "@google/genai";

const app = express();
const port = 3001;
app.use(express.json());

let nextId = 4;
let campaigns = [
  { id: 1, name: "Spring Launch", audience: "Remote professionals", budget: 4800, status: "Active", clicks: 1240, conversions: 86 },
  { id: 2, name: "Creator Retargeting", audience: "Trial users", budget: 2600, status: "Paused", clicks: 760, conversions: 41 },
  { id: 3, name: "Partner Newsletter", audience: "Agency partners", budget: 1400, status: "Draft", clicks: 0, conversions: 0 }
];

function normalizeCampaign(input = {}) {
  const name = typeof input.name === "string" ? input.name.trim() : "";
  const audience = typeof input.audience === "string" ? input.audience.trim() : "";
  const budget = Number(input.budget);
  const statuses = ["Draft", "Active", "Paused"];
  const status = statuses.includes(input.status) ? input.status : "Draft";
  if (!name || !audience || !Number.isFinite(budget) || budget < 0) return null;
  return { name, audience, budget, status };
}

app.get("/api/campaigns", (req, res) => res.json(campaigns));

app.post("/api/campaigns", (req, res) => {
  const data = normalizeCampaign(req.body ?? {});
  if (!data) return res.status(400).json({ error: "Valid name, audience, and budget are required." });
  const campaign = { id: nextId++, ...data, clicks: 0, conversions: 0 };
  campaigns.push(campaign);
  return res.status(201).json(campaign);
});

app.put("/api/campaigns/:id", (req, res) => {
  const index = campaigns.findIndex((item) => item.id === Number(req.params.id));
  const data = normalizeCampaign(req.body ?? {});
  if (index === -1) return res.status(404).json({ error: "Campaign not found." });
  if (!data) return res.status(400).json({ error: "Valid name, audience, and budget are required." });
  campaigns[index] = { ...campaigns[index], ...data };
  return res.json(campaigns[index]);
});

app.delete("/api/campaigns/:id", (req, res) => {
  const id = Number(req.params.id);
  if (!campaigns.some((item) => item.id === id)) return res.status(404).json({ error: "Campaign not found." });
  campaigns = campaigns.filter((item) => item.id !== id);
  return res.status(204).end();
});

const aiSchema = {
  type: "object",
  properties: {
    title: { type: "string" },
    summary: { type: "string" },
    copy: {
      type: "object",
      properties: { headline: { type: "string" }, body: { type: "string" }, cta: { type: "string" } },
      required: ["headline", "body", "cta"]
    },
    improvements: { type: "array", items: { type: "string" } },
    campaignDraft: {
      type: "object",
      properties: {
        name: { type: "string" }, audience: { type: "string" }, budget: { type: "integer", minimum: 0 },
        status: { type: "string", enum: ["Draft", "Active", "Paused"] }
      },
      required: ["name", "audience", "budget", "status"]
    }
  },
  required: ["title", "summary", "copy", "improvements", "campaignDraft"]
};

const actions = {
  copy: "Generate persuasive campaign copy from the brief.",
  analyze: "Analyze campaign performance and identify the strongest next step."
};

app.post("/api/ai", async (req, res) => {
  const body = req.body ?? {};
  const action = typeof body.action === "string" ? body.action : "";
  const brief = typeof body.brief === "string" ? body.brief.trim() : "";
  const context = Array.isArray(body.campaigns) ? body.campaigns : [];
  if (!actions[action]) return res.status(400).json({ error: "Choose a valid AI action." });
  if (!process.env.GEMINI_API_KEY) return res.status(503).json({ error: "GEMINI_API_KEY is missing." });

  try {
    const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
    const response = await ai.models.generateContent({
      model: "gemini-2.5-flash",
      contents: `You are a campaign copilot. ${actions[action]} Brief: ${brief || "None"}. Campaigns: ${JSON.stringify(context)}. Fill every schema field concisely.`,
      config: { responseMimeType: "application/json", responseSchema: aiSchema }
    });
    return res.json(JSON.parse(response.text));
  } catch (error) {
    console.error(error);
    return res.status(500).json({ error: "AI request failed. Check the server terminal and key." });
  }
});

app.listen(port, () => console.log(`Campaign API running on http://localhost:${port}`));

How to use this reference

Compare the order of your imports plus routes plus AI constants with this file. Keep your API key outside the source code.

Connect the React AI interface

The server can now generate structured campaign results. The remaining work sends the existing selector plus brief plus campaign collection to that server boundary.

  • At the top of src/App.jsx, find the current aiActions array.
  • Use this existing array as your reference:
const aiActions = [
  ["copy", "Generate campaign copy"],
  ["summary", "Summarize performance"],
  ["improvements", "Suggest improvements"],
  ["create", "Create from brief"]
];
  • Replace the four options with the two read-only actions below:
const aiActions = [
  ["copy", "Generate campaign copy"],
  ["analyze", "Analyze performance"]
];
  • Return to src/App.jsx.
  • Locate the closing line of the deleteCampaign function.
  • Add the AI request handler immediately below deleteCampaign.
  async function runAi(event) {
    event.preventDefault();
    setAiLoading(true);
    setAiResult(null);
    try {
      setAiResult(await api("/api/ai", { method: "POST", body: JSON.stringify({ action: aiAction, brief, campaigns }) }));
      setIsError(false);
    } catch (error) {
      setMessage(error.message);
      setIsError(true);
    } finally {
      setAiLoading(false);
    }
  }

What does this handler manage?

  • The handler prevents the browser form from reloading the page.
  • The aiLoading state disables repeat submissions while the request runs.
  • The request sends aiAction plus brief plus campaigns to /api/ai.
  • The finally block restores the button after success or failure.

The AI shell already has matching styles. Replacing it with the completed section connects the form submission plus result cards to runAi.

  • Find the existing <section className="panel ai"> block near the bottom of src/App.jsx.
  • Replace that entire AI shell with the completed section below:
      <section className="panel ai">
        <div className="heading"><h2>AI campaign copilot</h2><span className="pill">Gemini</span></div>
        <form className="ai-form" onSubmit={runAi}>
          <label>Action<select value={aiAction} onChange={(event) => setAiAction(event.target.value)}>{aiActions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
          <label>Campaign brief<textarea rows="4" value={brief} onChange={(event) => setBrief(event.target.value)} /></label>
          <button className="primary" disabled={aiLoading}>{aiLoading ? "Thinking..." : "Run AI action"}</button>
        </form>

        {aiResult && <div className="results">
          <article><h3>{aiResult.title}</h3><p>{aiResult.summary}</p></article>
          <article><h3>{aiResult.copy.headline}</h3><p>{aiResult.copy.body}</p><strong>{aiResult.copy.cta}</strong></article>
          <article><h3>Improvements</h3><ul>{aiResult.improvements.map((item) => <li key={item}>{item}</li>)}</ul></article>
          <article><h3>{aiResult.campaignDraft.name}</h3><p>{aiResult.campaignDraft.audience}</p><strong>${aiResult.campaignDraft.budget.toLocaleString()} · {aiResult.campaignDraft.status}</strong></article>
        </div>}
      </section>

How does the interface use the result?

  • The controlled selector stores copy or analyze in aiAction.
  • The controlled text area keeps the campaign brief in brief.
  • The button changes to Thinking... while the request is active.
  • The result grid renders every field guaranteed by aiSchema.
  • Save src/App.jsx.
  • Return to the Campaign Copilot dashboard in Microsoft Edge.

You should see the AI campaign copilot form below the campaign workspace. The action selector plus sample campaign brief should be ready to submit.

Before you run the first AI action, how do you expect campaign copy to differ from a performance analysis?

The first response can take a moment while the API generates every schema field. A brief wait here is expected.

  • Select Generate campaign copy in the Action field.
  • Click Run AI action.

The button should show Thinking... briefly. You should then see four result cards with a title plus summary plus campaign copy plus improvements plus a campaign draft.

  • Select Analyze performance in the Action field.
  • Click Run AI action.

The cards should update with a performance-focused title, summary, and next-step recommendations based on the current campaign metrics.

  • Review the request inside runAi in src/App.jsx.
  • Confirm that the browser request sends only action plus brief plus campaigns.

The browser code contains no GEMINI_API_KEY reference. The secret remains inside server.js and the Express environment.

AI result not appearing?

  • Restart the Express terminal if the dashboard reports that GEMINI_API_KEY is missing..
  • Check the Express terminal if the dashboard reports AI request failed. Check the server terminal and key..
  • Confirm that both the Express terminal plus Vite terminal remain running.

Get help without sharing the key: help me debug my Campaign Copilot AI request

✔️ My AI results appear

Your React form now receives structured campaign content through the secure Express endpoint. Keep both terminals running for the finished demo.

ⓧ I'd like to double check the full code

import { useEffect, useState } from "react";

const emptyForm = { name: "", audience: "", budget: 1000, status: "Draft" };
const aiActions = [
  ["copy", "Generate campaign copy"],
  ["analyze", "Analyze performance"]
];

async function api(path, options = {}) {
  const response = await fetch(path, {
    ...options,
    headers: { "Content-Type": "application/json", ...(options.headers ?? {}) }
  });
  if (response.status === 204) return null;
  const data = await response.json();
  if (!response.ok) throw new Error(data.error ?? "Request failed.");
  return data;
}

export default function App() {
  const [campaigns, setCampaigns] = useState([]);
  const [form, setForm] = useState(emptyForm);
  const [editingId, setEditingId] = useState(null);
  const [loading, setLoading] = useState(true);
  const [message, setMessage] = useState("");
  const [isError, setIsError] = useState(false);
  const [aiAction, setAiAction] = useState("copy");
  const [brief, setBrief] = useState("Launch a productivity app for remote creative teams.");
  const [aiResult, setAiResult] = useState(null);
  const [aiLoading, setAiLoading] = useState(false);

  async function loadCampaigns() {
    setLoading(true);
    try {
      setCampaigns(await api("/api/campaigns"));
      setIsError(false);
    } catch (error) {
      setMessage(error.message);
      setIsError(true);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => { loadCampaigns(); }, []);

  const totals = campaigns.reduce(
    (sum, item) => ({ budget: sum.budget + item.budget, clicks: sum.clicks + item.clicks, conversions: sum.conversions + item.conversions }),
    { budget: 0, clicks: 0, conversions: 0 }
  );

  function editCampaign(item) {
    setEditingId(item.id);
    setForm({ name: item.name, audience: item.audience, budget: item.budget, status: item.status });
  }

  async function saveCampaign(event) {
    event.preventDefault();
    try {
      await api(editingId ? `/api/campaigns/${editingId}` : "/api/campaigns", {
        method: editingId ? "PUT" : "POST",
        body: JSON.stringify({ ...form, budget: Number(form.budget) })
      });
      setForm(emptyForm);
      setEditingId(null);
      setMessage("Campaign saved.");
      setIsError(false);
      await loadCampaigns();
    } catch (error) {
      setMessage(error.message);
      setIsError(true);
    }
  }

  async function deleteCampaign(item) {
    if (!window.confirm(`Delete ${item.name}?`)) return;
    try {
      await api(`/api/campaigns/${item.id}`, { method: "DELETE" });
      setMessage("Campaign deleted.");
      setIsError(false);
      await loadCampaigns();
    } catch (error) {
      setMessage(error.message);
      setIsError(true);
    }
  }

  async function runAi(event) {
    event.preventDefault();
    setAiLoading(true);
    setAiResult(null);
    try {
      setAiResult(await api("/api/ai", { method: "POST", body: JSON.stringify({ action: aiAction, brief, campaigns }) }));
      setIsError(false);
    } catch (error) {
      setMessage(error.message);
      setIsError(true);
    } finally {
      setAiLoading(false);
    }
  }

  return (
    <main className="shell">
      <header><div><p className="eyebrow">Marketing workspace</p><h1>Campaign Copilot</h1><p>Full-stack CRUD with a focused AI assistant.</p></div><span className="pill">Demo ready</span></header>

      <section className="metrics">
        <article><span>Active</span><strong>{campaigns.filter((item) => item.status === "Active").length}</strong></article>
        <article><span>Budget</span><strong>${totals.budget.toLocaleString()}</strong></article>
        <article><span>Clicks</span><strong>{totals.clicks.toLocaleString()}</strong></article>
        <article><span>Conversions</span><strong>{totals.conversions.toLocaleString()}</strong></article>
      </section>

      {message && <p className={isError ? "message error" : "message success"}>{message}</p>}

      <section className="workspace">
        <div className="panel">
          <div className="heading"><h2>Campaigns</h2><span>{campaigns.length} total</span></div>
          {loading ? <p>Loading campaigns...</p> : campaigns.map((item) => (
            <article className="campaign" key={item.id}>
              <div><span className={`status ${item.status.toLowerCase()}`}>{item.status}</span><h3>{item.name}</h3><p>{item.audience}</p></div>
              <div className="numbers"><strong>${item.budget.toLocaleString()}</strong><span>{item.clicks} clicks · {item.conversions} conversions</span></div>
              <div className="actions"><button onClick={() => editCampaign(item)}>Edit</button><button className="danger" onClick={() => deleteCampaign(item)}>Delete</button></div>
            </article>
          ))}
        </div>

        <form className="panel form" onSubmit={saveCampaign}>
          <h2>{editingId ? "Edit campaign" : "New campaign"}</h2>
          <label>Name<input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></label>
          <label>Audience<input required value={form.audience} onChange={(event) => setForm({ ...form, audience: event.target.value })} /></label>
          <label>Budget<input required min="0" type="number" value={form.budget} onChange={(event) => setForm({ ...form, budget: event.target.value })} /></label>
          <label>Status<select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}><option>Draft</option><option>Active</option><option>Paused</option></select></label>
          <button className="primary" type="submit">{editingId ? "Save changes" : "Create campaign"}</button>
        </form>
      </section>

      <section className="panel ai">
        <div className="heading"><h2>AI campaign copilot</h2><span className="pill">Gemini</span></div>
        <form className="ai-form" onSubmit={runAi}>
          <label>Action<select value={aiAction} onChange={(event) => setAiAction(event.target.value)}>{aiActions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
          <label>Campaign brief<textarea rows="4" value={brief} onChange={(event) => setBrief(event.target.value)} /></label>
          <button className="primary" disabled={aiLoading}>{aiLoading ? "Thinking..." : "Run AI action"}</button>
        </form>

        {aiResult && <div className="results">
          <article><h3>{aiResult.title}</h3><p>{aiResult.summary}</p></article>
          <article><h3>{aiResult.copy.headline}</h3><p>{aiResult.copy.body}</p><strong>{aiResult.copy.cta}</strong></article>
          <article><h3>Improvements</h3><ul>{aiResult.improvements.map((item) => <li key={item}>{item}</li>)}</ul></article>
          <article><h3>{aiResult.campaignDraft.name}</h3><p>{aiResult.campaignDraft.audience}</p><strong>${aiResult.campaignDraft.budget.toLocaleString()} · {aiResult.campaignDraft.status}</strong></article>
        </div>}
      </section>
    </main>
  );
}

How to use this reference

Compare the placement of runAi plus the completed AI section with your file. The rest of the REST-backed dashboard should remain unchanged.

Your copilot can now generate copy and analyze performance without changing data. Next, you will give Gemini campaign tools, inspect its proposed actions, and require confirmation before any write runs.

Turn the Copilot into a Campaign Manager Agent

Your copilot now returns campaign copy and performance analysis without changing records. The next step turns that assistant into a Gemini agent that can inspect the collection and select a campaign tool.

Write actions need a tighter boundary than analysis. The agent will propose one change, show its arguments, and wait for your confirmation before Express updates the collection.

In this step, get ready to:
  • Declare read, create, update, and delete tools for Gemini.
  • Separate agent planning from confirmed execution.
  • Add proposal controls to React and test safe agent actions.
Declare the campaign tools

A function declaration tells Gemini which operations exist and which arguments each operation accepts. The server remains responsible for deciding whether a selected function is safe to run.

  • Stop the Express process with Ctrl+C.
  • In server.js, add the agent tool declarations below the actions object:
// Describe the campaign operations Gemini may select.
const campaignTools = [{
  functionDeclarations: [
    {
      name: "list_campaigns",
      description: "Inspect the current campaign collection.",
      parametersJsonSchema: { type: "object", properties: {} }
    },
    {
      name: "create_campaign",
      description: "Propose a new campaign.",
      parametersJsonSchema: { type: "object", properties: {
        name: { type: "string" }, audience: { type: "string" },
        budget: { type: "number" }, status: { type: "string", enum: ["Draft", "Active", "Paused"] }
      }, required: ["name", "audience", "budget", "status"] }
    },
    {
      name: "update_campaign",
      description: "Propose replacement values for one campaign.",
      parametersJsonSchema: { type: "object", properties: {
        id: { type: "number" }, name: { type: "string" }, audience: { type: "string" },
        budget: { type: "number" }, status: { type: "string", enum: ["Draft", "Active", "Paused"] }
      }, required: ["id", "name", "audience", "budget", "status"] }
    },
    {
      name: "delete_campaign",
      description: "Propose deleting one campaign by ID.",
      parametersJsonSchema: { type: "object", properties: { id: { type: "number" } }, required: ["id"] }
    }
  ] }];

What do these tools control?

  • The read tool has no arguments because it returns the current collection.
  • The create and update tools require complete campaign values that can pass normalizeCampaign().
  • The delete tool accepts only the campaign ID.
  • These declarations let Gemini select a function. They do not execute it.
  • Save server.js.
  • Restart Express by running:
npm run server

You should see the campaign API listener message. The tool declarations now parse without changing any campaign data.

Server stopped after adding the tools?

Check the braces around each parametersJsonSchema object. A missing comma between function declarations prevents Node.js from starting.

Help me check my campaign tool declarations.

Plan actions before execution

The planning loop may execute list_campaigns because reading does not change state. Create, update, and delete calls leave the loop as proposals for the learner to review.

  • Stop Express with Ctrl+C.
  • Add the planning function below campaignTools in server.js:
async function planAgentAction(goal) {
  const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
  const contents = [{ role: "user", parts: [{ text: `${goal}\nCurrent campaigns: ${JSON.stringify(campaigns)}` }] }];

  // Let the model inspect data before it proposes a write.
  for (let turn = 0; turn < 3; turn += 1) {
    const response = await ai.models.generateContent({
      model: "gemini-2.5-flash",
      contents,
      config: { tools: campaignTools, toolConfig: { functionCallingConfig: { mode: "AUTO" } } }
    });
    const call = response.functionCalls?.[0];
    if (!call) return { summary: response.text, proposal: null };
    if (call.name !== "list_campaigns") {
      return { summary: `Gemini proposes ${call.name}.`, proposal: call };
    }
    contents.push({ role: "model", parts: [{ functionCall: call }] });
    contents.push({ role: "user", parts: [{
      functionResponse: { name: call.name, response: { campaigns } }
    }] });
  }
  return { summary: "The agent reached its planning limit.", proposal: null };
}

How does the planning loop work?

  • The loop gives Gemini up to three turns to inspect the collection and choose a response.
  • A list_campaigns call receives the current array as a function response.
  • Any write call returns to React as a proposal instead of running immediately.
  • Add the confirmed-write helper below planAgentAction() by pasting:
// Apply only a write proposal that the learner confirmed.
function applyAgentProposal(proposal = {}) {
  const args = proposal.args ?? {};
  if (proposal.name === "create_campaign") {
    const data = normalizeCampaign(args);
    if (!data) throw new Error("The proposed campaign is invalid.");
    const campaign = { id: nextId++, ...data, clicks: 0, conversions: 0 };
    campaigns.push(campaign);
    return { message: "Agent created the campaign.", campaign };
  }
  if (proposal.name === "update_campaign") {
    const index = campaigns.findIndex((item) => item.id === Number(args.id));
    const data = normalizeCampaign(args);
    if (index === -1 || !data) throw new Error("The proposed update is invalid.");
    campaigns[index] = { ...campaigns[index], ...data };
    return { message: "Agent updated the campaign.", campaign: campaigns[index] };
  }
  if (proposal.name === "delete_campaign") {
    const id = Number(args.id);
    if (!campaigns.some((item) => item.id === id)) throw new Error("The campaign does not exist.");
    campaigns = campaigns.filter((item) => item.id !== id);
    return { message: "Agent deleted the campaign." };
  }
  throw new Error("The proposal is not an approved write action.");
}

Why validate twice?

This helper checks the tool name again after confirmation. It also reuses the existing campaign validator before creating or updating a record.

  • Add the planning and execution routes below applyAgentProposal():
// Return analysis or one proposed write without changing data.
app.post("/api/agent/plan", async (req, res) => {
  const goal = typeof req.body?.goal === "string" ? req.body.goal.trim() : "";
  if (!goal) return res.status(400).json({ error: "Enter an agent goal." });
  if (!process.env.GEMINI_API_KEY) return res.status(503).json({ error: "GEMINI_API_KEY is missing." });
  try {
    return res.json(await planAgentAction(goal));
  } catch (error) {
    console.error(error);
    return res.status(500).json({ error: "Agent planning failed." });
  }
});

// Execute the proposal only after React sends confirmation.
app.post("/api/agent/execute", (req, res) => {
  try {
    return res.json(applyAgentProposal(req.body?.proposal));
  } catch (error) {
    return res.status(400).json({ error: error.message });
  }
});

What do the two routes separate?

  • The planning route accepts a natural-language goal and returns analysis or one proposed function call.
  • The execution route accepts only the proposal that React sends after confirmation.
  • Keeping these routes separate makes the confirmation boundary visible in the network flow.
  • Save server.js.
  • Restart the completed server by running:
npm run server

You should see the listener message again. Express now exposes separate planning and confirmed-execution boundaries.

Agent routes preventing startup?

Confirm that both agent routes appear above app.listen(). Check the terminal's first named line for a missing brace or parenthesis.

Help me debug the agent routes.

Connect and test the agent controls

React needs one state value for the goal and one for the returned plan. The confirm handler sends the proposal back only after the learner chooses to execute it.

  • In src/App.jsx, add these states and handlers below runAi():
  const [agentGoal, setAgentGoal] = useState("Which active campaign has the most clicks?");
  const [agentPlan, setAgentPlan] = useState(null);
  const [agentLoading, setAgentLoading] = useState(false);

  // Ask Gemini to inspect data or propose one campaign change.
  async function planAgent(event) {
    event.preventDefault();
    setAgentLoading(true);
    try {
      setAgentPlan(await api("/api/agent/plan", {
        method: "POST", body: JSON.stringify({ goal: agentGoal })
      }));
      setIsError(false);
    } catch (error) {
      setMessage(error.message); setIsError(true);
    } finally { setAgentLoading(false); }
  }

  // Execute only the proposal the learner confirms.
  async function confirmAgent() {
    const result = await api("/api/agent/execute", {
      method: "POST", body: JSON.stringify({ proposal: agentPlan.proposal })
    });
    setMessage(result.message); setAgentPlan(null); setIsError(false);
    await loadCampaigns();
  }

How do the handlers enforce control?

  • The goal remains editable so the same interface can request analysis or a campaign change.
  • Planning never calls the execution route.
  • Confirmation reloads the collection after Express applies the approved proposal.
  • Add the agent panel below the existing AI section in src/App.jsx:
      {/* Show analysis directly and gate every proposed write. */}
      <section className="panel ai">
        <div className="heading"><h2>Campaign manager agent</h2><span className="pill">Tool use</span></div>
        <form className="form" onSubmit={planAgent}>
          <label>Agent goal<textarea rows="3" value={agentGoal} onChange={(event) => setAgentGoal(event.target.value)} /></label>
          <button className="primary" disabled={agentLoading}>{agentLoading ? "Planning..." : "Plan action"}</button>
        </form>

        {agentPlan && <div className="proposal">
          <h3>Agent plan</h3>
          <p>{agentPlan.summary}</p>
          {agentPlan.proposal && <>
            <pre>{JSON.stringify(agentPlan.proposal.args, null, 2)}</pre>
            <div className="actions">
              <button className="primary" onClick={confirmAgent}>Confirm action</button>
              <button onClick={() => setAgentPlan(null)}>Cancel</button>
            </div>
          </>}
        </div>}
      </section>

What does the learner see?

  • Read-only answers show the agent summary without confirmation controls.
  • Write proposals display their arguments before the buttons appear.
  • Cancel clears the proposal without calling Express.
  • Save src/App.jsx.
  • Return to the dashboard in Microsoft Edge.
  • Click Plan action for the existing read-only goal.

You should see an answer about the active campaign collection without confirmation buttons. The agent inspected data without changing it.

  • Replace the Agent goal with Delete the Partner Newsletter campaign.
  • Click Plan action.
  • Review the proposed campaign ID.
  • Click Cancel.

Partner Newsletter should remain visible. That canceled deletion proves a model-selected tool cannot bypass your confirmation.

Before the final run, do you expect a confirmed create proposal to appear after a browser refresh?

  • Enter Create a Draft campaign named Agent Demo for engineering managers with a budget of 1500 in the Agent goal field.
  • Click Plan action.
  • Review the proposed values.
  • Click Confirm action.
  • Refresh Microsoft Edge.

You should see the Agent Demo card after the refresh. The agent selected a tool, you approved its arguments, and Express applied the change.

Agent plan or confirmation failing?

Check that the Express and Vite terminals are still running. If planning works but confirmation fails, confirm that React sends agentPlan.proposal to /api/agent/execute.

Help me debug the agent workflow.

Your agent can now inspect campaigns, propose CRUD actions, and execute only the changes you approve. Next, you will make the architecture visible and run one final end-to-end demonstration.

Validate and Present the Full-Stack System

Your Gemini agent can inspect campaign data and execute confirmed changes. The final step turns the system into a presentation-ready demo with visible architecture and repeatable proof.

You will add high-level design, UX, and data-flow views to the React interface. You will then validate the full path through Vite, Express, REST, and Gemini.

In this step, get ready to:
  • Display high-level design, UX, and data-flow diagrams in the application.
  • Run a complete create, read, update, delete, and refresh test.
  • Demonstrate AI output, agent inspection, and confirmation-gated execution.
Add the architecture diagrams

A demo becomes easier to explain when the interface shows how its layers connect. Each diagram focuses on one question: what runs where, what the learner does, and how data moves.

  • In src/App.jsx, add the architecture section below the campaign manager agent panel:
      {/* Present the system from architecture, UX, and data perspectives. */}
      <section className="architecture">
        <div className="heading"><h2>System design walkthrough</h2><span className="pill">Demo guide</span></div>
        <div className="diagrams">
          <article className="panel">
            <h3>High-level design</h3>
            <div className="flow"><span>Browser</span><b>to</b><span>React and Vite</span><b>to</b><span>Express API</span><b>to</b><span>Campaign store and Gemini</span></div>
          </article>
          <article className="panel">
            <h3>UX flow</h3>
            <div className="flow"><span>Choose an action</span><b>to</b><span>Enter campaign details</span><b>to</b><span>Review feedback or a proposal</span><b>to</b><span>Confirm or cancel</span></div>
          </article>
          <article className="panel">
            <h3>Data flow</h3>
            <div className="flow"><span>User input</span><b>to</b><span>Fetch request</span><b>to</b><span>REST or agent route</span><b>to</b><span>Validation or tool call</span><b>to</b><span>JSON and React state</span></div>
          </article>
        </div>
      </section>

What does each diagram explain?

  • The high-level design separates browser, frontend, server, and external-service responsibilities.
  • The UX flow follows the choices and feedback a learner sees during the demo.
  • The data flow traces values from an input through the server and back into rendered state.
  • Save src/App.jsx.
  • Return to Microsoft Edge.

You should see a System design walkthrough below the agent panel. Each diagram should already show its labeled stages in order.

  • In src/styles.css, append the diagram styles below the existing media queries:
/* Present each architecture flow as connected, readable stages. */
.architecture { margin-top: 18px; }
.diagrams { display: grid; gap: 14px; }
.flow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.flow span { padding: 10px 12px; border: 1px solid #d8ddea; border-radius: 10px; background: #f6f7fb; font-weight: 750; }
.flow b { color: #6858ff; font-size: .78rem; text-transform: uppercase; }
@media (max-width: 650px) {
  .flow { align-items: stretch; flex-direction: column; }
  .flow b { text-align: center; }
}

How are the flows presented?

  • Each stage uses a bordered box so the system boundary remains visible.
  • Flexible wrapping keeps long flows readable on desktop widths.
  • The mobile rule stacks every stage vertically on narrow screens.
  • Save src/styles.css.
  • Refresh Microsoft Edge.
  • Narrow the browser window to test the stacked diagram layout.

You should see three white diagram panels with connected stage boxes. On a narrow screen, each flow should stack without horizontal scrolling.

Diagrams missing or unstyled?

Confirm that the architecture section remains inside the returned <main> element. Check that styles.css contains every closing brace in the media rule.

Help me debug the architecture diagrams.

Validate the complete CRUD loop

A reliable demo uses one recognizable record from creation through deletion. This makes every state change easy to identify in the dashboard and metrics.

  • Confirm that the Express and Vite terminals are running.
  • Enter Demo Validation in the Name field.
  • Enter Product leaders in the Audience field.
  • Enter 3000 in the Budget field.
  • Select Active in the Status field.
  • Click Create campaign.

You should see the Demo Validation card, a success message, and updated campaign metrics. That proves the create request completed through the REST API.

  • Refresh Microsoft Edge.
  • Click Edit on the Demo Validation card.
  • Change the Budget value to 3600.
  • Click Save changes.

The card should remain after the refresh and then show a budget of $3,600. The total budget should change with the returned server collection.

Before you delete the record, do you expect it to return after the next browser refresh?

  • Click Delete on the Demo Validation card.
  • Confirm the browser dialog.
  • Refresh Microsoft Edge.

Demo Validation should remain absent after the refresh. You have now demonstrated create, read, update, and delete through the browser-to-server boundary.

One CRUD operation failed?

Check the dashboard message area for the server's validation response. If a deleted record returns, confirm that the Express process was not restarted because the project uses an in-memory store.

Help me debug the CRUD validation.

Demonstrate AI, agent, and system flow

The final demonstration connects the output-only copilot, the tool-using agent, and the architecture views. Each result should match one path shown in the diagrams.

  • Select Analyze performance in the copilot's Action field.
  • Click Run AI action.
  • Confirm that the result cards reference the current campaign collection.

The structured result proves React can send context through Express to Gemini and render the returned JSON.

  • Enter Which campaign currently has the highest budget? in the Agent goal field.
  • Click Plan action.
  • Confirm that the answer appears without write controls.

The read-only response proves the agent can inspect the campaign collection without opening a write path.

  • Enter Create a Draft campaign named Final Demo for technology founders with a budget of 2400 in the Agent goal field.
  • Click Plan action.
  • Review the proposed function arguments.
  • Click Confirm action.
  • Refresh Microsoft Edge.

You should see Final Demo after the refresh, structured AI results above it, and all three architecture diagrams below it. That is the complete demo path from UX action to data flow and visible result.

Final AI or agent demo failed?

If the AI or agent request fails, read the dashboard message before changing code. Confirm that Express can access GEMINI_API_KEY and that both local processes remain running.

Help me debug the final system demo.

You have built and validated a demo-ready full-stack application. Its CRUD operations, REST boundaries, AI output, controlled agent writes, UX flow, and data flow are all visible in one working system.

Secret mission

Scale the Agent Across Multiple Campaigns

Extend the campaign agent with bulk status updates and bulk deletions. Preview every affected campaign ID, cancel one destructive proposal, then confirm a safe multi-record change.

Clean Up Your Resources

Clean Up Your Resources

Campaign Copilot runs locally, so stopped development servers create no ongoing compute cost. Decide whether to keep your resources running, pause them for later, or delete them entirely.

Cost warning

Input tokens and output tokens for gemini-3.8-flash are free of charge on the Gemini Developer API free tier. Rate limits still apply.

Free-tier prompts and responses may be used to improve Google products. Separately enabled paid API usage can create charges.

Resources you used:

  • The campaign-copilot folder containing the source files and installed npm packages.
  • The Vite development server running in a Visual Studio Code terminal.
  • The Express API process containing the in-memory campaign collection.
  • The Windows GEMINI_API_KEY environment entry.
  • The Gemini API key linked to a Google Cloud project.

Keep everything running

No action is needed. Choose this if you are still demonstrating Campaign Copilot or extending its features.

  • Keep the Vite development server running in its Visual Studio Code terminal.
  • Keep the Express API running in its Visual Studio Code terminal.
  • Keep the campaign-copilot folder for future development.
  • Use only non-sensitive learning data in Gemini requests.

Campaigns created through the dashboard remain available while Express keeps running. Stopping Express clears those in-memory records.

Pause - I'll come back to this later

Shut down both local processes to free up memory while keeping the project files for later.

  • Press Ctrl+C in the Visual Studio Code terminal running the Express API.
  • Press Ctrl+C in the Visual Studio Code terminal running the Vite development server.

Your source files and installed npm packages stay inside campaign-copilot. Any campaigns added during this session are discarded when Express stops.

Your Gemini API key remains active. The stopped local processes make no API calls.

Delete - I don't want to use this again

Remove the local workspace and revoke the credential when you no longer need Campaign Copilot.

  • Press Ctrl+C in the Visual Studio Code terminal running the Express API.
  • Press Ctrl+C in the Visual Studio Code terminal running the Vite development server.
  • Close Visual Studio Code.
  • Delete the campaign-copilot folder with Windows File Explorer from the location where you created it.
  • Confirm that campaign-copilot no longer appears in that location.

Your local workspace is now cleared. Removing campaign-copilot also removes its installed npm packages.

Revoking the API key stops future requests that use that credential. Remove its environment entry as well so the old value does not remain on your workstation.

  • Remove GEMINI_API_KEY from Windows Environment Variables.
  • Select API Keys in Google AI Studio.
  • Revoke the Gemini API key used for Campaign Copilot.
  • Confirm that the key is no longer active.

You have now removed the local files, running processes, in-memory records, environment entry, and API credential.

Nice Work!

Nice Work!

Nice work! You built and validated a responsive React application with an Express REST API and a confirmation-gated Gemini agent. You also made the system explainable through high-level design, UX, and data-flow diagrams.

You learned how to:

  • Build a responsive full-stack CRUD workspace with controlled forms, live campaign metrics, and visible loading, success, and error states.
  • Protect GEMINI_API_KEY on the server. Combine structured AI output with an agent tool loop that previews create, update, and delete proposals before execution.
  • Validate the complete browser-to-server flow through create, read, update, delete, refresh, AI, and agent tests. Use high-level design, UX, and data-flow diagrams to present the result.
  • Secret Mission: Extend the agent with bulk status updates and deletions. Preview every affected campaign ID before canceling or confirming the multi-record write.

Ready to quiz yourself?