{ "name": "stagepick", "version": "1.0.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start" }, "dependencies": { "@supabase/supabase-js": "^2.45.0", "framer-motion": "^11.3.0", "html5-qrcode": "^2.3.8", "lucide-react": "^0.424.0", "next": "14.2.5", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.4.19", "postcss": "^8.4.39", "tailwindcss": "^3.4.6", "typescript": "^5" } } { "compilerOptions": { "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [{ "name": "next" }], "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] }/** @type {import('next').NextConfig} */ const nextConfig = { images: { remotePatterns: [ { protocol: "https", hostname: "images.unsplash.com" }, { protocol: "https", hostname: "**.supabase.co" } ] } }; export default nextConfig;const config = { plugins: { tailwindcss: {}, autoprefixer: {} } };import type { Config } from "tailwindcss"; const config: Config = { content: [ "./app/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}" ], theme: { extend: {} }, plugins: [] }; export default config;@tailwind base; @tailwind components; @tailwind utilities; body { font-family: Inter, system-ui, -apple-system, sans-serif; } .pt-safe { padding-top: env(safe-area-inset-top); } .safe-top { padding-top: max(0.75rem, env(safe-area-inset-top)); } .safe-bottom { padding-bottom: max(1.25rem, env(safe-area-inset-bottom)); } export default config;import "./globals.css"; import { AuthProvider } from "@/components/AuthProvider"; export const metadata = { title: "StagePick – Live Picking Dashboard", description: "Professional warehouse picking dashboard for home staging" }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children}
Hosted on VibeDeploy
); }import { redirect } from "next/navigation"; export default function Home() { redirect("/dashboard"); }export type FeedEventType = "picked" | "at_dock" | "loaded" | "started" | "moved"; export type FeedEvent = { id: string; type: FeedEventType; user_name: string; item_name: string; item_image?: string | null; quantity?: number; job_name?: string | null; location?: string | null; sortly_item_id?: string | null; created_at: string; }; export type ItemStatus = "ready" | "picking" | "picked" | "at_dock"; export type PickItem = { id: string; sortly_id: string; name: string; description: string; image: string; qty_needed: number; qty_picked: number; bin: string; status: ItemStatus; job_id: string; barcode?: string | null; }; export type DockTruck = { id: string; name: string; bay: string; status: "loading" | "waiting" | "ready"; progress: number; updated_at: string; }; export type UserRole = "designer" | "warehouse" | "admin"; export type Profile = { id: string; full_name: string | null; role: UserRole; avatar_url: string | null; };import { createClient } from "@supabase/supabase-js"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ""; const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || ""; export const supabase = createClient(supabaseUrl, supabaseAnonKey); export const supabaseAdmin = createClient(supabaseUrl, serviceRoleKey);import { supabase } from "./supabase"; import type { Profile } from "./types"; export async function getProfile(userId: string): Promise { const { data, error } = await supabase.from("profiles").select("*").eq("id", userId).single(); if (error || !data) return null; return data as Profile; } export async function signIn(email: string, password: string) { return supabase.auth.signInWithPassword({ email, password }); } export async function signOut() { return supabase.auth.signOut();const BASE = process.env.SORTLY_API_BASE || "https://api.sortly.co/api/v1"; const KEY = process.env.SORTLY_API_KEY || ""; async function sortlyFetch(path: string, options: RequestInit = {}) { if (!KEY) return null; const res = await fetch(`${BASE}${path}`, { ...options, headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...(options.headers || {}) } }); if (!res.ok) throw new Error(`Sortly API error ${res.status}`); if (res.status === 204) return null; return res.json(); } export async function moveItem(itemId: string, folderId: string, quantity: number) { return sortlyFetch(`/items/${itemId}/move`, { method: "POST", body: JSON.stringify({ folder_id: folderId, quantity }) }); } }"use client"; import { createContext, useContext, useEffect, useState } from "react"; import { supabase } from "@/lib/supabase"; import type { User } from "@supabase/supabase-js"; import type { Profile } from "@/lib/types"; import { getProfile } from "@/lib/auth"; type AuthContextType = { user: User | null; profile: Profile | null; loading: boolean; signOut: () => Promise; }; const AuthContext = createContext({ user: null, profile: null, loading: true, signOut: async () => {} }); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { if (!process.env.NEXT_PUBLIC_SUPABASE_URL) { setUser({ id: "demo-user" } as User); setProfile({ id: "demo-user", full_name: "Demo Warehouse", role: "warehouse", avatar_url: null }); setLoading(false); return; } supabase.auth.getSession().then(async ({ data: { session } }) => { setUser(session?.user ?? null); if (session?.user) setProfile(await getProfile(session.user.id)); setLoading(false); }); const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_e, session) => { setUser(session?.user ?? null); if (session?.user) setProfile(await getProfile(session.user.id)); else setProfile(null); setLoading(false); }); return () => subscription.unsubscribe(); }, []); const handleSignOut = async () => { if (process.env.NEXT_PUBLIC_SUPABASE_URL) await supabase.auth.signOut(); setUser(null); setProfile(null); }; return ( {children} ); } export const useAuth = () => useContext(AuthContext);"use client"; import { useAuth } from "./AuthProvider"; import Link from "next/link"; type Props = { jobName: string; pickedCount: number; totalCount: number }; export default function Header({ jobName, pickedCount, totalCount }: Props) { const { profile, signOut } = useAuth(); const progress = totalCount === 0 ? 0 : Math.round((pickedCount / totalCount) * 100); return (
SP
StagePick
Job: {jobName}
Live
{pickedCount}/{totalCount} • {progress}%
Mobile View

{profile?.full_name || "User"}

{profile?.role || "warehouse"}

); }"use client"; import { CheckCircle2, Loader2 } from "lucide-react"; import type { PickItem } from "@/lib/types"; import { useState } from "react"; const statusStyles = { ready: "bg-emerald-50 text-emerald-700 border-emerald-100", picking: "bg-amber-50 text-amber-700 border-amber-100", picked: "bg-blue-50 text-blue-700 border-blue-100", at_dock: "bg-violet-50 text-violet-700 border-violet-100" }; export default function ItemCard({ item, onPicked }: { item: PickItem; onPicked: (item: PickItem) => Promise }) { const [loading, setLoading] = useState(false); const handlePick = async () => { setLoading(true); try { await onPicked(item); } finally { setLoading(false); } }; return (
{item.name} {item.status.replace("_", " ")}

{item.name}

{item.description}

{item.qty_picked} of {item.qty_needed} {item.bin}
{(item.status === "ready" || item.status === "picking") && ( )}
); }"use client"; import type { DockTruck } from "@/lib/types"; export default function DockQueue({ trucks }: { trucks: DockTruck[] }) { return (

Dock Queue

Live
{trucks.map((truck) => (

{truck.name} • {truck.bay}

{truck.status}

))}
); }"use client"; import { useEffect, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Package, Truck, CheckCircle2, Clock } from "lucide-react"; import { supabase } from "@/lib/supabase"; import type { FeedEvent, FeedEventType } from "@/lib/types"; const typeConfig: Record = { picked: { icon: CheckCircle2, color: "text-emerald-600", bg: "bg-emerald-50" }, at_dock: { icon: Package, color: "text-blue-600", bg: "bg-blue-50" }, loaded: { icon: Truck, color: "text-violet-600", bg: "bg-violet-50" }, started: { icon: Clock, color: "text-amber-600", bg: "bg-amber-50" }, moved: { icon: Package, color: "text-slate-600", bg: "bg-slate-50" } }; function timeAgo(dateStr: string) { const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); if (seconds < 60) return `${seconds}s ago`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; return `${Math.floor(seconds / 3600)}h ago`; } const DEMO_EVENTS: FeedEvent[] = [ { id: "d1", type: "picked", user_name: "Sarah K.", item_name: "Winslow Armchair", item_image: "https://images.unsplash.com/photo-1586023492125-27b2c045efd7?w=100", quantity: 1, job_name: "123 Maple Ave", location: "A08-04-01", created_at: new Date(Date.now() - 40000).toISOString() }, { id: "d2", type: "at_dock", user_name: "Mike T.", item_name: "Savoy 3-Seater Sofa", item_image: "https://images.unsplash.com/photo-1555041469-a586c61ea9bc?w=100", quantity: 1, job_name: "123 Maple Ave", location: "Dock Bay D1", created_at: new Date(Date.now() - 120000).toISOString() }, { id: "d3", type: "started", user_name: "Alex R.", item_name: "Milo Coffee Table", item_image: "https://images.unsplash.com/photo-1533090481720-856c6e3c1fdc?w=100", quantity: 1, job_name: "123 Maple Ave", created_at: new Date(Date.now() - 180000).toISOString() }, { id: "d4", type: "loaded", user_name: "System", item_name: "Haven Table Lamp", quantity: 1, job_name: "123 Maple Ave", location: "Truck 17", created_at: new Date(Date.now() - 240000).toISOString() } ]; export default function LivePickFeed() { const [events, setEvents] = useState(DEMO_EVENTS); const hasSupabase = !!process.env.NEXT_PUBLIC_SUPABASE_URL; useEffect(() => { if (!hasSupabase) return; supabase.from("pick_events").select("*").order("created_at", { ascending: false }).limit(30) .then(({ data }) => { if (data?.length) setEvents(data as FeedEvent[]); }); const channel = supabase.channel("pick-feed") .on("postgres_changes", { event: "INSERT", schema: "public", table: "pick_events" }, (payload) => { setEvents((prev) => [payload.new as FeedEvent, ...prev].slice(0, 40)); }).subscribe(); return () => { supabase.removeChannel(channel); }; }, [hasSupabase]); return (

Live Pick Feed

Live
{events.map((event) => { const config = typeConfig[event.type] || typeConfig.moved; const Icon = config.icon; return (
{event.item_image ? ( {event.item_name} ) : (
)}

{event.user_name}{" "} {event.type === "picked" && "picked"} {event.type === "at_dock" && "moved to dock"} {event.type === "loaded" && "loaded"} {event.type === "started" && "started picking"} {event.type === "moved" && "moved"} {" "} {event.item_name}

{timeAgo(event.created_at)}
{event.job_name && {event.job_name}} {event.location && (<>•{event.location})}
); })}
); }"use client"; import { useState } from "react"; import { CheckCircle2, Loader2, MapPin } from "lucide-react"; import type { PickItem } from "@/lib/types"; export default function MobilePickCard({ item, onPicked }: { item: PickItem; onPicked: (item: PickItem) => Promise }) { const [loading, setLoading] = useState(false); const handlePick = async () => { setLoading(true); try { await onPicked(item); } finally { setLoading(false); } }; const isDone = item.status === "picked" || item.status === "at_dock"; return (
{item.name} {isDone && (
)}

{item.name}

{item.description}

{item.bin}
{item.qty_picked}/{item.qty_needed}
{!isDone ? ( ) : (
✓ Picked
)}
); }"use client"; import { useEffect, useRef, useState } from "react"; import { Html5Qrcode, Html5QrcodeScannerState } from "html5-qrcode"; import { X, Camera, SwitchCamera } from "lucide-react"; export default function BarcodeScanner({ onScan, onClose }: { onScan: (code: string) => void; onClose: () => void }) { const scannerRef = useRef(null); const [error, setError] = useState(null); const [facingMode, setFacingMode] = useState<"environment" | "user">("environment"); const [isStarting, setIsStarting] = useState(true); const lastScanRef = useRef(""); useEffect(() => { const scanner = new Html5Qrcode("barcode-scanner"); scannerRef.current = scanner; const start = async () => { setIsStarting(true); setError(null); try { await scanner.start( { facingMode }, { fps: 10, qrbox: { width: 280, height: 180 }, aspectRatio: 1.777 }, (text) => { if (text === lastScanRef.current) return; lastScanRef.current = text; onScan(text); if (navigator.vibrate) navigator.vibrate(100); }, () => {} ); } catch (err: any) { setError(err?.message?.includes("Permission") || err?.name === "NotAllowedError" ? "Camera permission denied." : "Unable to start camera."); } finally { setIsStarting(false); } }; start(); return () => { if (scannerRef.current) { const state = scannerRef.current.getState(); if (state === Html5QrcodeScannerState.SCANNING) { scannerRef.current.stop().catch(() => {}).finally(() => scannerRef.current?.clear()); } else { try { scannerRef.current.clear(); } catch {} } } }; }, [facingMode, onScan]); return (

Scan Barcode / QR

{isStarting && (
Starting camera...
)}
{error ?

{error}

:

Point at the barcode or QR code on the item

}
); }