- Add DispensarySchedule page showing crawl history and upcoming schedule - Add /dispensaries/:state/:city/:slug/schedule route - Add API endpoint for store crawl history - Update View Schedule link to use dispensary-specific route - Remove colored badges from DispensaryDetail product table (plain text) - Make Details button ghost style in product table - Add "Sort by States" option to IntelligenceBrands - Remove status filter dropdown from Dispensaries page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
469 lines
20 KiB
TypeScript
469 lines
20 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Layout } from '../components/Layout';
|
|
import { api } from '../lib/api';
|
|
import { Building2, Phone, Mail, MapPin, ExternalLink, Search, Eye, Pencil, X, Save, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
|
|
const PAGE_SIZE = 50;
|
|
|
|
export function Dispensaries() {
|
|
const navigate = useNavigate();
|
|
const [dispensaries, setDispensaries] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
const [filterState, setFilterState] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState('');
|
|
const [editingDispensary, setEditingDispensary] = useState<any | null>(null);
|
|
const [editForm, setEditForm] = useState<any>({});
|
|
const [total, setTotal] = useState(0);
|
|
const [offset, setOffset] = useState(0);
|
|
const [hasMore, setHasMore] = useState(false);
|
|
const [states, setStates] = useState<string[]>([]);
|
|
|
|
// Debounce search
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setDebouncedSearch(searchTerm);
|
|
setOffset(0); // Reset to first page on search
|
|
}, 300);
|
|
return () => clearTimeout(timer);
|
|
}, [searchTerm]);
|
|
|
|
// Load states once for filter dropdown
|
|
useEffect(() => {
|
|
const loadStates = async () => {
|
|
try {
|
|
const data = await api.getDispensaries({ limit: 500, crawl_enabled: 'all' });
|
|
const uniqueStates = Array.from(new Set(data.dispensaries.map((d: any) => d.state).filter(Boolean))).sort() as string[];
|
|
setStates(uniqueStates);
|
|
} catch (error) {
|
|
console.error('Failed to load states:', error);
|
|
}
|
|
};
|
|
loadStates();
|
|
}, []);
|
|
|
|
const loadDispensaries = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await api.getDispensaries({
|
|
limit: PAGE_SIZE,
|
|
offset,
|
|
search: debouncedSearch || undefined,
|
|
state: filterState || undefined,
|
|
status: filterStatus || undefined,
|
|
crawl_enabled: 'all'
|
|
});
|
|
setDispensaries(data.dispensaries);
|
|
setTotal(data.total);
|
|
setHasMore(data.hasMore);
|
|
} catch (error) {
|
|
console.error('Failed to load dispensaries:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [offset, debouncedSearch, filterState, filterStatus]);
|
|
|
|
useEffect(() => {
|
|
loadDispensaries();
|
|
}, [loadDispensaries]);
|
|
|
|
const handleEdit = (dispensary: any) => {
|
|
setEditingDispensary(dispensary);
|
|
setEditForm({
|
|
dba_name: dispensary.dba_name || '',
|
|
website: dispensary.website || '',
|
|
phone: dispensary.phone || '',
|
|
google_rating: dispensary.google_rating || '',
|
|
google_review_count: dispensary.google_review_count || ''
|
|
});
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!editingDispensary) return;
|
|
|
|
try {
|
|
await api.updateDispensary(editingDispensary.id, editForm);
|
|
await loadDispensaries();
|
|
setEditingDispensary(null);
|
|
setEditForm({});
|
|
} catch (error) {
|
|
console.error('Failed to update dispensary:', error);
|
|
alert('Failed to update dispensary');
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
setEditingDispensary(null);
|
|
setEditForm({});
|
|
};
|
|
|
|
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
|
|
|
const goToPage = (page: number) => {
|
|
const newOffset = (page - 1) * PAGE_SIZE;
|
|
setOffset(newOffset);
|
|
};
|
|
|
|
const handleStateFilter = (state: string) => {
|
|
setFilterState(state);
|
|
setOffset(0); // Reset to first page
|
|
};
|
|
|
|
const handleStatusFilter = (status: string) => {
|
|
setFilterStatus(status);
|
|
setOffset(0); // Reset to first page
|
|
};
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Dispensaries</h1>
|
|
<p className="text-sm text-gray-600 mt-1">
|
|
USA and Canada Dispensary Directory ({total} total)
|
|
</p>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Search
|
|
</label>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
placeholder="Search by name or company..."
|
|
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Filter by State
|
|
</label>
|
|
<select
|
|
value={filterState}
|
|
onChange={(e) => handleStateFilter(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option value="">All States</option>
|
|
{states.map(state => (
|
|
<option key={state} value={state}>{state}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
{loading ? (
|
|
<div className="text-center py-12">
|
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>
|
|
<p className="mt-2 text-sm text-gray-600">Loading dispensaries...</p>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">
|
|
Name
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">
|
|
Address
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">
|
|
City
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">
|
|
Phone
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">
|
|
Email
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">
|
|
Website
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200">
|
|
{dispensaries.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">
|
|
No dispensaries found
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
dispensaries.map((disp) => (
|
|
<tr key={disp.id} className="hover:bg-gray-50">
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<Building2 className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-medium text-gray-900">
|
|
{disp.dba_name || disp.name}
|
|
</span>
|
|
{disp.dba_name && disp.google_rating && (
|
|
<span className="text-xs text-gray-500">
|
|
⭐ {disp.google_rating} ({disp.google_review_count} reviews)
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-start gap-1">
|
|
<MapPin className="w-3 h-3 text-gray-400 flex-shrink-0 mt-0.5" />
|
|
<span className="text-sm text-gray-600">{disp.address1 || '-'}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-sm text-gray-600">{disp.city || '-'}</span>
|
|
{disp.zip && (
|
|
<span className="text-xs text-gray-400 ml-1">({disp.zip})</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{disp.phone ? (
|
|
<div className="flex items-center gap-1">
|
|
<Phone className="w-3 h-3 text-gray-400" />
|
|
<span className="text-sm text-gray-600">
|
|
{disp.phone.replace(/(\d{3})(\d{3})(\d{4})/, '($1) $2-$3')}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<span className="text-sm text-gray-400">-</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{disp.email ? (
|
|
<div className="flex items-center gap-1">
|
|
<Mail className="w-3 h-3 text-gray-400" />
|
|
<a
|
|
href={`mailto:${disp.email}`}
|
|
className="text-sm text-blue-600 hover:text-blue-800"
|
|
>
|
|
{disp.email}
|
|
</a>
|
|
</div>
|
|
) : (
|
|
<span className="text-sm text-gray-400">-</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{disp.website ? (
|
|
<a
|
|
href={disp.website}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
|
|
>
|
|
<ExternalLink className="w-3 h-3" />
|
|
<span>Visit Site</span>
|
|
</a>
|
|
) : (
|
|
<span className="text-sm text-gray-400">-</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleEdit(disp)}
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 rounded-lg transition-colors"
|
|
title="Edit"
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
const citySlug = disp.city.toLowerCase().replace(/\s+/g, '-');
|
|
navigate(`/dispensaries/${disp.state}/${citySlug}/${disp.slug}`);
|
|
}}
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-lg transition-colors"
|
|
title="View"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Footer with Pagination */}
|
|
<div className="bg-gray-50 px-4 py-3 border-t border-gray-200">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-gray-600">
|
|
Showing {offset + 1}-{Math.min(offset + dispensaries.length, total)} of {total} dispensaries
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => goToPage(currentPage - 1)}
|
|
disabled={currentPage === 1}
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
Prev
|
|
</button>
|
|
<span className="text-sm text-gray-600">
|
|
Page {currentPage} of {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => goToPage(currentPage + 1)}
|
|
disabled={!hasMore}
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Next
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Edit Modal */}
|
|
{editingDispensary && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
|
{/* Modal Header */}
|
|
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
|
<h2 className="text-xl font-bold text-gray-900">
|
|
Edit Dispensary: {editingDispensary.name}
|
|
</h2>
|
|
<button
|
|
onClick={handleCancel}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
>
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Modal Body */}
|
|
<div className="p-6 space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
DBA Name (Display Name)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={editForm.dba_name}
|
|
onChange={(e) => setEditForm({ ...editForm, dba_name: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="e.g., Green Med Wellness"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Website
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={editForm.website}
|
|
onChange={(e) => setEditForm({ ...editForm, website: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="https://example.com"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Phone Number
|
|
</label>
|
|
<input
|
|
type="tel"
|
|
value={editForm.phone}
|
|
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="5551234567"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Google Rating
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
min="0"
|
|
max="5"
|
|
value={editForm.google_rating}
|
|
onChange={(e) => setEditForm({ ...editForm, google_rating: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="4.5"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Review Count
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
value={editForm.google_review_count}
|
|
onChange={(e) => setEditForm({ ...editForm, google_review_count: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="123"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Read-only info */}
|
|
<div className="bg-gray-50 p-4 rounded-lg space-y-2">
|
|
<div className="text-sm">
|
|
<span className="font-medium text-gray-700">AZDHS Name:</span>{' '}
|
|
<span className="text-gray-600">{editingDispensary.name}</span>
|
|
</div>
|
|
<div className="text-sm">
|
|
<span className="font-medium text-gray-700">Address:</span>{' '}
|
|
<span className="text-gray-600">
|
|
{editingDispensary.address}, {editingDispensary.city}, {editingDispensary.state} {editingDispensary.zip}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal Footer */}
|
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
|
<button
|
|
onClick={handleCancel}
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
Save Changes
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Layout>
|
|
);
|
|
}
|