Files
cannaiq/cannaiq/src/pages/Dispensaries.tsx
Kelly d102d27731 feat(admin): Dispensary schedule page and UI cleanup
- 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>
2025-12-10 23:50:47 -07:00

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>
);
}