Initial commit - Dutchie dispensary scraper

This commit is contained in:
Kelly
2025-11-28 19:45:44 -07:00
commit 5757a8e9bd
23375 changed files with 3788799 additions and 0 deletions

View File

@@ -0,0 +1,212 @@
import { ReactNode } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import {
LayoutDashboard,
Store,
Building2,
FolderOpen,
Package,
Target,
TrendingUp,
Wrench,
Activity,
Shield,
FileText,
Settings,
LogOut,
CheckCircle,
Key
} from 'lucide-react';
interface LayoutProps {
children: ReactNode;
}
interface NavLinkProps {
to: string;
icon: ReactNode;
label: string;
isActive: boolean;
}
function NavLink({ to, icon, label, isActive }: NavLinkProps) {
return (
<a
href={to}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-blue-50 text-blue-600'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
<span className="flex-shrink-0">{icon}</span>
<span>{label}</span>
</a>
);
}
interface NavSectionProps {
title: string;
children: ReactNode;
}
function NavSection({ title, children }: NavSectionProps) {
return (
<div className="space-y-1">
<div className="px-3 mb-2">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
{title}
</h3>
</div>
{children}
</div>
);
}
export function Layout({ children }: LayoutProps) {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const handleLogout = () => {
logout();
navigate('/login');
};
const isActive = (path: string, exact = true) => {
if (exact) {
return location.pathname === path;
}
return location.pathname.startsWith(path);
};
return (
<div className="flex min-h-screen bg-gray-50">
{/* Sidebar */}
<div
className="w-64 bg-white border-r border-gray-200 flex flex-col"
style={{
position: 'sticky',
top: 0,
height: '100vh',
overflowY: 'auto'
}}
>
{/* Logo/Brand */}
<div className="px-6 py-5 border-b border-gray-200">
<h1 className="text-lg font-semibold text-gray-900">Dutchie Analytics</h1>
<p className="text-xs text-gray-500 mt-0.5">{user?.email}</p>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-6">
<NavSection title="Main">
<NavLink
to="/"
icon={<LayoutDashboard className="w-4 h-4" />}
label="Dashboard"
isActive={isActive('/', true)}
/>
<NavLink
to="/dispensaries"
icon={<Building2 className="w-4 h-4" />}
label="Dispensaries"
isActive={isActive('/dispensaries')}
/>
<NavLink
to="/categories"
icon={<FolderOpen className="w-4 h-4" />}
label="Categories"
isActive={isActive('/categories')}
/>
<NavLink
to="/products"
icon={<Package className="w-4 h-4" />}
label="Products"
isActive={isActive('/products')}
/>
<NavLink
to="/campaigns"
icon={<Target className="w-4 h-4" />}
label="Campaigns"
isActive={isActive('/campaigns')}
/>
<NavLink
to="/analytics"
icon={<TrendingUp className="w-4 h-4" />}
label="Analytics"
isActive={isActive('/analytics')}
/>
</NavSection>
<NavSection title="Scraper">
<NavLink
to="/scraper-tools"
icon={<Wrench className="w-4 h-4" />}
label="Tools"
isActive={isActive('/scraper-tools')}
/>
<NavLink
to="/scraper-monitor"
icon={<Activity className="w-4 h-4" />}
label="Monitor"
isActive={isActive('/scraper-monitor')}
/>
</NavSection>
<NavSection title="System">
<NavLink
to="/changes"
icon={<CheckCircle className="w-4 h-4" />}
label="Change Approval"
isActive={isActive('/changes')}
/>
<NavLink
to="/api-permissions"
icon={<Key className="w-4 h-4" />}
label="API Permissions"
isActive={isActive('/api-permissions')}
/>
<NavLink
to="/proxies"
icon={<Shield className="w-4 h-4" />}
label="Proxies"
isActive={isActive('/proxies')}
/>
<NavLink
to="/logs"
icon={<FileText className="w-4 h-4" />}
label="Logs"
isActive={isActive('/logs')}
/>
<NavLink
to="/settings"
icon={<Settings className="w-4 h-4" />}
label="Settings"
isActive={isActive('/settings')}
/>
</NavSection>
</nav>
{/* Logout */}
<div className="px-3 py-4 border-t border-gray-200">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-red-600 hover:bg-red-50 transition-colors"
>
<LogOut className="w-4 h-4" />
<span>Logout</span>
</button>
</div>
</div>
{/* Main Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-7xl mx-auto px-8 py-8">
{children}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../lib/api';
import { AlertTriangle, X } from 'lucide-react';
export function PendingChangesAlert() {
const navigate = useNavigate();
const [pendingCount, setPendingCount] = useState(0);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
loadStats();
// Refresh stats every 30 seconds
const interval = setInterval(loadStats, 30000);
return () => clearInterval(interval);
}, []);
const loadStats = async () => {
try {
const stats = await api.getChangeStats();
setPendingCount(stats.pending_count);
} catch (error) {
console.error('Failed to load change stats:', error);
}
};
if (pendingCount === 0 || dismissed) {
return null;
}
return (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-600" />
<div>
<p className="text-sm font-medium text-yellow-800">
{pendingCount} pending change{pendingCount !== 1 ? 's' : ''} require{pendingCount === 1 ? 's' : ''} review
</p>
<p className="text-sm text-yellow-700">
Proposed changes to dispensary data are waiting for approval
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => navigate('/changes')}
className="px-4 py-2 bg-yellow-600 text-white text-sm font-medium rounded-lg hover:bg-yellow-700"
>
Review Changes
</button>
<button
onClick={() => setDismissed(true)}
className="p-1 text-yellow-600 hover:text-yellow-800"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { ReactNode, useEffect } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
interface PrivateRouteProps {
children: ReactNode;
}
export function PrivateRoute({ children }: PrivateRouteProps) {
const { isAuthenticated, checkAuth } = useAuthStore();
useEffect(() => {
checkAuth();
}, []);
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,71 @@
import { useEffect } from 'react';
interface ToastProps {
message: string;
type: 'success' | 'error' | 'info';
onClose: () => void;
duration?: number;
}
export function Toast({ message, type, onClose, duration = 4000 }: ToastProps) {
useEffect(() => {
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
const bgColors = {
success: '#10b981',
error: '#ef4444',
info: '#3b82f6'
};
return (
<div
style={{
position: 'fixed',
top: '20px',
right: '20px',
background: bgColors[type],
color: 'white',
padding: '16px 24px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
zIndex: 9999,
maxWidth: '400px',
animation: 'slideIn 0.3s ease-out',
fontSize: '14px',
fontWeight: '500'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ flex: 1, whiteSpace: 'pre-wrap' }}>{message}</div>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: 'white',
cursor: 'pointer',
fontSize: '18px',
padding: '0 4px',
opacity: 0.8
}}
>
×
</button>
</div>
<style>{`
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`}</style>
</div>
);
}