Add local product detail page with Dutchie comparison

- Add ProductDetail page for viewing products locally
- Add Dutchie and Details buttons to product cards in Products and StoreDetail pages
- Add Last Updated display showing data freshness
- Add parallel scrape scripts and routes
- Add K8s deployment configurations
- Add frontend Dockerfile with nginx

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-11-30 06:34:38 -07:00
parent 6e597f15ca
commit 8b4292fbb2
34 changed files with 1613 additions and 552 deletions

View File

@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Login } from './pages/Login';
import { Dashboard } from './pages/Dashboard';
import { Products } from './pages/Products';
import { ProductDetail } from './pages/ProductDetail';
import { Stores } from './pages/Stores';
import { Dispensaries } from './pages/Dispensaries';
import { DispensaryDetail } from './pages/DispensaryDetail';
@@ -27,6 +28,7 @@ export default function App() {
<Route path="/login" element={<Login />} />
<Route path="/" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
<Route path="/products" element={<PrivateRoute><Products /></PrivateRoute>} />
<Route path="/products/:id" element={<PrivateRoute><ProductDetail /></PrivateRoute>} />
<Route path="/stores" element={<PrivateRoute><Stores /></PrivateRoute>} />
<Route path="/dispensaries" element={<PrivateRoute><Dispensaries /></PrivateRoute>} />
<Route path="/dispensaries/:state/:city/:slug" element={<PrivateRoute><DispensaryDetail /></PrivateRoute>} />

View File

@@ -0,0 +1,269 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
import { ArrowLeft, ExternalLink, Package } from 'lucide-react';
export function ProductDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [product, setProduct] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadProduct();
}, [id]);
const loadProduct = async () => {
if (!id) return;
setLoading(true);
setError(null);
try {
const data = await api.getProduct(parseInt(id));
setProduct(data.product);
} catch (err: any) {
setError(err.message || 'Failed to load product');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div>
</div>
</Layout>
);
}
if (error || !product) {
return (
<Layout>
<div className="text-center py-12">
<Package className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-gray-900 mb-2">Product not found</h2>
<p className="text-gray-500 mb-4">{error}</p>
<button
onClick={() => navigate(-1)}
className="text-blue-600 hover:text-blue-700"
>
Go back
</button>
</div>
</Layout>
);
}
const metadata = product.metadata || {};
const getImageUrl = () => {
if (product.image_url_full) return product.image_url_full;
if (product.medium_path) return `http://localhost:9020/dutchie/${product.medium_path}`;
if (product.thumbnail_path) return `http://localhost:9020/dutchie/${product.thumbnail_path}`;
return null;
};
const imageUrl = getImageUrl();
return (
<Layout>
<div className="max-w-6xl mx-auto">
{/* Back button */}
<button
onClick={() => navigate(-1)}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6"
>
<ArrowLeft className="w-4 h-4" />
Back
</button>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 p-6">
{/* Product Image */}
<div className="aspect-square bg-gray-50 rounded-lg overflow-hidden">
{imageUrl ? (
<img
src={imageUrl}
alt={product.name}
className="w-full h-full object-contain"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<Package className="w-24 h-24" />
</div>
)}
</div>
{/* Product Info */}
<div className="space-y-6">
{/* Header */}
<div>
<div className="flex items-center gap-2 mb-2">
{product.in_stock ? (
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded">
In Stock
</span>
) : (
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs font-medium rounded">
Out of Stock
</span>
)}
{product.strain_type && (
<span className="px-2 py-1 bg-purple-100 text-purple-700 text-xs font-medium rounded capitalize">
{product.strain_type}
</span>
)}
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{product.name}</h1>
{product.brand && (
<p className="text-lg text-gray-600 font-medium">{product.brand}</p>
)}
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
{product.store_name && <span>{product.store_name}</span>}
{product.category_name && (
<>
<span></span>
<span>{product.category_name}</span>
</>
)}
</div>
</div>
{/* Price */}
{product.price !== null && (
<div className="border-t border-gray-100 pt-4">
<div className="text-3xl font-bold text-blue-600">
${parseFloat(product.price).toFixed(2)}
</div>
{product.weight && (
<div className="text-sm text-gray-500 mt-1">
{product.weight}
</div>
)}
</div>
)}
{/* THC/CBD */}
{(product.thc_percentage || product.cbd_percentage) && (
<div className="border-t border-gray-100 pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Cannabinoid Content</h3>
<div className="grid grid-cols-2 gap-4">
{product.thc_percentage !== null && (
<div className="bg-green-50 rounded-lg p-3">
<div className="text-xs text-gray-500 uppercase">THC</div>
<div className="text-xl font-bold text-green-600">{product.thc_percentage}%</div>
</div>
)}
{product.cbd_percentage !== null && (
<div className="bg-blue-50 rounded-lg p-3">
<div className="text-xs text-gray-500 uppercase">CBD</div>
<div className="text-xl font-bold text-blue-600">{product.cbd_percentage}%</div>
</div>
)}
</div>
</div>
)}
{/* Description */}
{product.description && (
<div className="border-t border-gray-100 pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-2">Description</h3>
<p className="text-gray-600 text-sm leading-relaxed">{product.description}</p>
</div>
)}
{/* Terpenes */}
{metadata.terpenes && metadata.terpenes.length > 0 && (
<div className="border-t border-gray-100 pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-2">Terpenes</h3>
<div className="flex flex-wrap gap-2">
{metadata.terpenes.map((terp: string) => (
<span
key={terp}
className="px-2 py-1 bg-amber-100 text-amber-700 text-xs font-medium rounded"
>
{terp}
</span>
))}
</div>
</div>
)}
{/* Effects */}
{metadata.effects && metadata.effects.length > 0 && (
<div className="border-t border-gray-100 pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-2">Effects</h3>
<div className="flex flex-wrap gap-2">
{metadata.effects.map((effect: string) => (
<span
key={effect}
className="px-2 py-1 bg-indigo-100 text-indigo-700 text-xs font-medium rounded"
>
{effect}
</span>
))}
</div>
</div>
)}
{/* Flavors */}
{metadata.flavors && metadata.flavors.length > 0 && (
<div className="border-t border-gray-100 pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-2">Flavors</h3>
<div className="flex flex-wrap gap-2">
{metadata.flavors.map((flavor: string) => (
<span
key={flavor}
className="px-2 py-1 bg-pink-100 text-pink-700 text-xs font-medium rounded"
>
{flavor}
</span>
))}
</div>
</div>
)}
{/* Lineage */}
{metadata.lineage && (
<div className="border-t border-gray-100 pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-2">Lineage</h3>
<p className="text-gray-600 text-sm">{metadata.lineage}</p>
</div>
)}
{/* View on Dutchie link */}
{product.dutchie_url && (
<div className="border-t border-gray-100 pt-4">
<a
href={product.dutchie_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-blue-600 hover:text-blue-700"
>
View on Dutchie
<ExternalLink className="w-4 h-4" />
</a>
</div>
)}
{/* Last updated */}
{product.last_seen_at && (
<div className="text-xs text-gray-400 pt-4 border-t border-gray-100">
Last updated: {new Date(product.last_seen_at).toLocaleString()}
</div>
)}
</div>
</div>
</div>
</div>
</Layout>
);
}

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
export function Products() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const [products, setProducts] = useState<any[]>([]);
const [stores, setStores] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
@@ -322,7 +323,7 @@ export function Products() {
marginBottom: '20px'
}}>
{products.map(product => (
<ProductCard key={product.id} product={product} />
<ProductCard key={product.id} product={product} onViewDetails={() => navigate(`/products/${product.id}`)} />
))}
</div>
@@ -391,15 +392,27 @@ export function Products() {
);
}
function ProductCard({ product }: { product: any }) {
function ProductCard({ product, onViewDetails }: { product: any; onViewDetails: () => void }) {
const formatDate = (dateStr: string) => {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
};
return (
<div style={{
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden',
transition: 'transform 0.2s',
cursor: 'pointer'
transition: 'transform 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.transform = 'translateY(-4px)'}
onMouseLeave={(e) => e.currentTarget.style.transform = 'translateY(0)'}
@@ -442,7 +455,7 @@ function ProductCard({ product }: { product: any }) {
}}>
{product.name}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<div style={{ fontWeight: 'bold', color: '#667eea' }}>
{product.price ? `$${product.price}` : 'N/A'}
</div>
@@ -456,6 +469,62 @@ function ProductCard({ product }: { product: any }) {
{product.in_stock ? 'In Stock' : 'Out of Stock'}
</div>
</div>
{/* Last Updated */}
<div style={{
fontSize: '11px',
color: '#888',
marginBottom: '12px',
borderTop: '1px solid #eee',
paddingTop: '8px'
}}>
Last Updated: {formatDate(product.last_seen_at)}
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: '8px' }}>
{product.dutchie_url && (
<a
href={product.dutchie_url}
target="_blank"
rel="noopener noreferrer"
style={{
flex: 1,
padding: '8px 12px',
background: '#f0f0f0',
color: '#333',
textDecoration: 'none',
borderRadius: '6px',
fontSize: '12px',
fontWeight: '500',
textAlign: 'center',
border: '1px solid #ddd'
}}
onClick={(e) => e.stopPropagation()}
>
Dutchie
</a>
)}
<button
onClick={(e) => {
e.stopPropagation();
onViewDetails();
}}
style={{
flex: 1,
padding: '8px 12px',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '12px',
fontWeight: '500',
cursor: 'pointer'
}}
>
Details
</button>
</div>
</div>
</div>
);

View File

@@ -333,6 +333,26 @@ export function StoreDetail() {
Updated: {new Date(product.last_seen_at).toLocaleDateString()}
</p>
)}
{/* Action Buttons */}
<div className="flex gap-2 mt-3 pt-3 border-t border-gray-100">
{product.dutchie_url && (
<a
href={product.dutchie_url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 px-3 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors text-center border border-gray-200"
>
Dutchie
</a>
)}
<button
onClick={() => navigate(`/products/${product.id}`)}
className="flex-1 px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Details
</button>
</div>
</div>
</div>
))}