Reeve
DevelopersTutorials

Shopify Sales Dashboard

Build a live Shopify sales dashboard with revenue charts and order tables — step by step in 15 minutes.

Build a Shopify Sales Dashboard in 15 Minutes

In this tutorial, you'll build a real-time sales dashboard that shows revenue trends, recent orders, and top products — all pulling live data from the merchant's connected Shopify store.

What you'll build:

  • Revenue summary cards (today, this week, this month)
  • Recent orders table with status badges
  • Top products by revenue
  • Auto-refresh every 60 seconds

Skills covered: reeve.data.query, CSS variables for theming, React state management

Time: ~15 minutes


1. Scaffold the Project

npx create-reeve-app shopify-dashboard --template react-app
cd shopify-dashboard
npm install

Install a lightweight charting library:

npm install recharts

2. Update the Manifest

Open reeve-manifest.json and declare the data.shopify permission:

{
  "id": "com.yourcompany.shopify-dashboard",
  "name": "Shopify Sales Dashboard",
  "version": "1.0.0",
  "description": "Real-time revenue, orders, and product analytics for your Shopify store.",
  "author": {
    "name": "Your Name",
    "email": "you@yourcompany.com"
  },
  "category": "analytics",
  "tags": ["shopify", "revenue", "dashboard", "analytics"],
  "permissions": ["data.shopify"],
  "entrypoint": "index.html",
  "icon": "public/icon.png"
}

3. Build the Dashboard Component

Replace src/App.tsx with the full dashboard:

import { useEffect, useState, useCallback } from 'react';
import { reeve } from '@reeve/app-sdk';
import {
  AreaChart,
  Area,
  XAxis,
  YAxis,
  Tooltip,
  ResponsiveContainer,
} from 'recharts';
import './App.css';

interface Order {
  id: string;
  name: string;
  total_price: string;
  financial_status: string;
  fulfillment_status: string | null;
  created_at: string;
  customer: { first_name: string; last_name: string } | null;
}

interface Product {
  id: string;
  title: string;
  revenue: number;
  units_sold: number;
}

interface RevenuePoint {
  date: string;
  revenue: number;
}

interface DashboardData {
  todayRevenue: number;
  weekRevenue: number;
  monthRevenue: number;
  orderCount: number;
  recentOrders: Order[];
  topProducts: Product[];
  revenueChart: RevenuePoint[];
}

function formatCurrency(amount: number) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    minimumFractionDigits: 0,
  }).format(amount);
}

function formatDate(iso: string) {
  return new Date(iso).toLocaleDateString('en-US', {
    month: 'short',
    day: 'numeric',
  });
}

function StatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    paid: 'var(--reeve-success)',
    pending: 'var(--reeve-warning)',
    refunded: 'var(--reeve-danger)',
    fulfilled: 'var(--reeve-accent)',
    unfulfilled: 'var(--reeve-text-muted)',
  };
  return (
    <span
      style={{
        color: colors[status] ?? 'var(--reeve-text-muted)',
        fontSize: '0.75rem',
        fontWeight: 600,
        textTransform: 'capitalize',
      }}
    >
      {status}
    </span>
  );
}

export default function App() {
  const [data, setData] = useState<DashboardData | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [lastUpdated, setLastUpdated] = useState<Date | null>(null);

  const loadData = useCallback(async () => {
    try {
      // Fetch recent orders (last 30 days)
      const thirtyDaysAgo = new Date();
      thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);

      const [ordersResult, productsResult] = await Promise.all([
        reeve.data.query({
          source: 'shopify',
          type: 'orders',
          filters: {
            created_at_min: thirtyDaysAgo.toISOString(),
            status: 'any',
          },
          limit: 250,
          fields: [
            'id', 'name', 'total_price', 'financial_status',
            'fulfillment_status', 'created_at', 'customer', 'line_items',
          ],
        }),
        reeve.data.query({
          source: 'shopify',
          type: 'products',
          limit: 10,
          fields: ['id', 'title', 'revenue', 'units_sold'],
        }),
      ]);

      const orders = ordersResult.data as Order[];

      // Calculate revenue metrics
      const now = new Date();
      const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
      const weekStart = new Date(now);
      weekStart.setDate(now.getDate() - 7);

      let todayRevenue = 0;
      let weekRevenue = 0;
      let monthRevenue = 0;

      // Build revenue chart (last 14 days)
      const chartMap: Record<string, number> = {};
      for (let i = 13; i >= 0; i--) {
        const d = new Date();
        d.setDate(d.getDate() - i);
        const key = d.toISOString().split('T')[0];
        chartMap[key] = 0;
      }

      orders.forEach((order) => {
        const amount = parseFloat(order.total_price);
        const orderDate = new Date(order.created_at);
        monthRevenue += amount;
        if (orderDate >= weekStart) weekRevenue += amount;
        if (orderDate >= todayStart) todayRevenue += amount;

        const key = order.created_at.split('T')[0];
        if (key in chartMap) {
          chartMap[key] += amount;
        }
      });

      const revenueChart: RevenuePoint[] = Object.entries(chartMap).map(
        ([date, revenue]) => ({
          date: formatDate(date),
          revenue: Math.round(revenue),
        })
      );

      setData({
        todayRevenue,
        weekRevenue,
        monthRevenue,
        orderCount: orders.length,
        recentOrders: orders.slice(0, 8),
        topProducts: (productsResult.data as Product[]).slice(0, 5),
        revenueChart,
      });
      setLastUpdated(new Date());
      setError(null);
    } catch (err) {
      setError('Failed to load Shopify data. Make sure Shopify is connected.');
      console.error(err);
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    reeve.init().then(loadData);

    // Auto-refresh every 60 seconds
    const interval = setInterval(loadData, 60_000);
    return () => clearInterval(interval);
  }, [loadData]);

  if (loading) {
    return (
      <div className="loading-screen">
        <div className="spinner" />
        <p>Loading dashboard...</p>
      </div>
    );
  }

  if (error) {
    return (
      <div className="error-screen">
        <p>⚠️ {error}</p>
        <button onClick={loadData}>Retry</button>
      </div>
    );
  }

  return (
    <div className="dashboard">
      {/* Header */}
      <div className="dashboard-header">
        <h1>Sales Dashboard</h1>
        {lastUpdated && (
          <span className="last-updated">
            Updated {lastUpdated.toLocaleTimeString()}
          </span>
        )}
      </div>

      {/* Revenue cards */}
      <div className="stat-grid">
        <StatCard label="Today" value={formatCurrency(data!.todayRevenue)} />
        <StatCard label="Last 7 days" value={formatCurrency(data!.weekRevenue)} />
        <StatCard label="Last 30 days" value={formatCurrency(data!.monthRevenue)} />
        <StatCard label="Orders (30d)" value={data!.orderCount.toString()} />
      </div>

      {/* Revenue chart */}
      <div className="card">
        <h2>Revenue (14 days)</h2>
        <ResponsiveContainer width="100%" height={180}>
          <AreaChart data={data!.revenueChart}>
            <defs>
              <linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
                <stop offset="5%" stopColor="var(--reeve-accent)" stopOpacity={0.3} />
                <stop offset="95%" stopColor="var(--reeve-accent)" stopOpacity={0} />
              </linearGradient>
            </defs>
            <XAxis
              dataKey="date"
              tick={{ fontSize: 11, fill: 'var(--reeve-text-muted)' }}
              axisLine={false}
              tickLine={false}
            />
            <YAxis
              tick={{ fontSize: 11, fill: 'var(--reeve-text-muted)' }}
              axisLine={false}
              tickLine={false}
              tickFormatter={(v) => `$${v}`}
            />
            <Tooltip
              contentStyle={{
                background: 'var(--reeve-surface)',
                border: '1px solid var(--reeve-border)',
                borderRadius: 'var(--reeve-radius)',
                color: 'var(--reeve-text)',
              }}
              formatter={(v: number) => [formatCurrency(v), 'Revenue']}
            />
            <Area
              type="monotone"
              dataKey="revenue"
              stroke="var(--reeve-accent)"
              strokeWidth={2}
              fill="url(#revenueGrad)"
            />
          </AreaChart>
        </ResponsiveContainer>
      </div>

      <div className="two-col">
        {/* Recent orders */}
        <div className="card">
          <h2>Recent Orders</h2>
          <table className="orders-table">
            <thead>
              <tr>
                <th>Order</th>
                <th>Customer</th>
                <th>Status</th>
                <th>Total</th>
              </tr>
            </thead>
            <tbody>
              {data!.recentOrders.map((order) => (
                <tr key={order.id}>
                  <td className="order-name">{order.name}</td>
                  <td>
                    {order.customer
                      ? `${order.customer.first_name} ${order.customer.last_name}`
                      : 'Guest'}
                  </td>
                  <td>
                    <StatusBadge status={order.financial_status} />
                  </td>
                  <td>${parseFloat(order.total_price).toFixed(2)}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>

        {/* Top products */}
        <div className="card">
          <h2>Top Products</h2>
          {data!.topProducts.map((product, i) => (
            <div key={product.id} className="product-row">
              <span className="product-rank">#{i + 1}</span>
              <span className="product-name">{product.title}</span>
              <span className="product-revenue">
                {formatCurrency(product.revenue)}
              </span>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

function StatCard({ label, value }: { label: string; value: string }) {
  return (
    <div className="stat-card">
      <div className="stat-label">{label}</div>
      <div className="stat-value">{value}</div>
    </div>
  );
}

4. Add the Styles

Replace src/App.css:

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: var(--reeve-font);
  background: var(--reeve-bg);
  color: var(--reeve-text);
  font-size: 14px;
}

.dashboard {
  padding: 20px;
  max-width: 1200px;
  margin: 0 auto;
}

.dashboard-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 20px;
}

.dashboard-header h1 {
  font-size: 1.4rem;
  font-weight: 700;
}

.last-updated {
  font-size: 0.75rem;
  color: var(--reeve-text-muted);
}

.stat-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 12px;
  margin-bottom: 20px;
}

.stat-card {
  background: var(--reeve-surface);
  border: 1px solid var(--reeve-border);
  border-radius: var(--reeve-radius);
  padding: 16px;
}

.stat-label {
  font-size: 0.75rem;
  color: var(--reeve-text-muted);
  margin-bottom: 6px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.stat-value {
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--reeve-text);
}

.card {
  background: var(--reeve-surface);
  border: 1px solid var(--reeve-border);
  border-radius: var(--reeve-radius);
  padding: 20px;
  margin-bottom: 20px;
}

.card h2 {
  font-size: 0.9rem;
  font-weight: 600;
  margin-bottom: 16px;
  color: var(--reeve-text);
}

.two-col {
  display: grid;
  grid-template-columns: 3fr 2fr;
  gap: 16px;
}

.orders-table {
  width: 100%;
  border-collapse: collapse;
}

.orders-table th {
  text-align: left;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--reeve-text-muted);
  padding: 0 8px 10px 0;
  border-bottom: 1px solid var(--reeve-border);
}

.orders-table td {
  padding: 10px 8px 10px 0;
  border-bottom: 1px solid var(--reeve-border);
  font-size: 0.85rem;
}

.orders-table tr:last-child td {
  border-bottom: none;
}

.order-name {
  font-family: monospace;
  color: var(--reeve-accent);
}

.product-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 0;
  border-bottom: 1px solid var(--reeve-border);
}

.product-row:last-child {
  border-bottom: none;
}

.product-rank {
  font-size: 0.75rem;
  color: var(--reeve-text-muted);
  width: 24px;
}

.product-name {
  flex: 1;
  font-size: 0.875rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.product-revenue {
  font-weight: 600;
  font-size: 0.875rem;
}

.loading-screen,
.error-screen {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100vh;
  gap: 12px;
  color: var(--reeve-text-muted);
}

.spinner {
  width: 32px;
  height: 32px;
  border: 3px solid var(--reeve-border);
  border-top-color: var(--reeve-accent);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

button {
  background: var(--reeve-accent);
  color: white;
  border: none;
  border-radius: var(--reeve-radius);
  padding: 8px 16px;
  cursor: pointer;
  font-size: 0.875rem;
}

button:hover {
  background: var(--reeve-accent-hover);
}

@media (max-width: 768px) {
  .stat-grid {
    grid-template-columns: repeat(2, 1fr);
  }
  .two-col {
    grid-template-columns: 1fr;
  }
}

5. Test It

npm run dev

Open the Cockpit → Settings → Apps → Install from URLhttp://localhost:5173. Navigate to your app in the sidebar. You'll see live Shopify data populating the dashboard.

Try switching the Cockpit to light mode — the CSS variables update automatically and the dashboard themes correctly.

If Shopify isn't connected for the test account, the dashboard shows an error. Connect Shopify first in Connectors → Shopify.


6. What's Next?

Extend the dashboard with:

  • Date picker — let the merchant choose a custom range
  • Export button — download orders as CSV using reeve.storage
  • AI insights — add a "Summarize trends" button using reeve.ai.complete()
  • Alerts — subscribe to shopify:order_created events for a live order feed

See the SDK Reference for all available APIs.

On this page