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:
52
frontend/Dockerfile
Normal file
52
frontend/Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
# Build stage
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Set build-time environment variable for API URL
|
||||
ENV VITE_API_URL=https://dispos.crawlsy.com
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy custom nginx config for SPA routing
|
||||
RUN echo 'server { \
|
||||
listen 80; \
|
||||
server_name _; \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
\
|
||||
# Gzip compression \
|
||||
gzip on; \
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; \
|
||||
\
|
||||
# Cache static assets \
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { \
|
||||
expires 1y; \
|
||||
add_header Cache-Control "public, immutable"; \
|
||||
} \
|
||||
\
|
||||
# SPA fallback - serve index.html for all routes \
|
||||
location / { \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -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>} />
|
||||
|
||||
269
frontend/src/pages/ProductDetail.tsx
Normal file
269
frontend/src/pages/ProductDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
|
||||
Reference in New Issue
Block a user