444 lines
19 KiB
JavaScript
444 lines
19 KiB
JavaScript
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import apiClient from '../api/client';
|
|
import { useBusiness } from '../context/BusinessContext';
|
|
import RegisterBusinessModal from '../components/RegisterBusinessModal';
|
|
import { fetchActiveSalesChannels } from '../utils/fyndSalesChannels';
|
|
import {
|
|
getBusinessDomain,
|
|
getBusinessImage,
|
|
getBusinessName,
|
|
getBusinessTagline,
|
|
getChannelId,
|
|
} from '../utils/businessProfile';
|
|
|
|
function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
|
|
<div className="bg-white border border-gray-200 rounded-xl p-8 w-full max-w-md shadow-xl">
|
|
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mx-auto mb-4">
|
|
<span className="text-xl">🗑</span>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900 text-center mb-2">Delete Business?</h3>
|
|
<p className="text-sm text-gray-500 text-center mb-6">
|
|
This will permanently delete <span className="text-gray-900 font-medium">{businessName}</span> and all its events, templates, and images. This cannot be undone.
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={onCancel}
|
|
disabled={deleting}
|
|
className="flex-1 py-2.5 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={onConfirm}
|
|
disabled={deleting}
|
|
className="flex-1 py-2.5 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition shadow-sm disabled:opacity-50 flex items-center justify-center gap-2"
|
|
>
|
|
{deleting ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Deleting…</> : 'Yes, Delete'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BusinessCreatedModal({ business, onClose }) {
|
|
const name = getBusinessName(business);
|
|
const domain = getBusinessDomain(business);
|
|
const tagline = getBusinessTagline(business);
|
|
const image = getBusinessImage(business);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
|
|
<div className="bg-white border border-gray-200 rounded-xl p-8 w-full max-w-md shadow-xl">
|
|
<div className="w-14 h-14 rounded-full bg-green-50 text-green-600 flex items-center justify-center mx-auto mb-4 text-2xl">✓</div>
|
|
<h2 className="text-xl font-bold text-gray-900 mb-2 text-center">Business Added!</h2>
|
|
<p className="text-gray-500 text-sm mb-4 font-medium text-center">Your business is ready for onboarding.</p>
|
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 mb-6">
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-16 h-16 rounded-2xl overflow-hidden bg-white border border-gray-200 shadow-sm shrink-0 flex items-center justify-center">
|
|
{image ? (
|
|
<img src={image} alt={name} className="w-full h-full object-cover" />
|
|
) : (
|
|
<span className="text-xl font-bold text-indigo-600">{name?.[0]?.toUpperCase() || 'B'}</span>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-indigo-600 font-bold text-lg tracking-tight truncate">{name}</p>
|
|
{domain && <p className="text-sm text-gray-500 font-medium truncate mt-0.5">{domain}</p>}
|
|
{tagline && <p className="text-sm text-gray-700 mt-2 leading-relaxed line-clamp-2">{tagline}</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 shadow-sm text-white font-medium transition"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SalesChannelCard({ channel, disabled, onImport }) {
|
|
const name = getBusinessName(channel);
|
|
const domain = getBusinessDomain(channel);
|
|
const image = getBusinessImage(channel);
|
|
|
|
return (
|
|
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition hover:border-indigo-300 hover:shadow-md">
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-12 h-12 rounded-xl overflow-hidden bg-gray-50 border border-gray-200 shrink-0 flex items-center justify-center">
|
|
{image ? (
|
|
<img src={image} alt={name} className="w-full h-full object-cover" />
|
|
) : (
|
|
<span className="text-sm font-bold text-indigo-600">{name?.[0]?.toUpperCase() || 'S'}</span>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-semibold text-gray-900 truncate">{name}</p>
|
|
<p className="text-sm text-gray-500 truncate mt-1">{domain || 'Domain unavailable'}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex items-center justify-between gap-3">
|
|
<span className="text-xs text-gray-500 font-medium">
|
|
{channel.websiteUrl ? 'Ready to scrape' : 'Use manual URL fallback'}
|
|
</span>
|
|
<button
|
|
onClick={onImport}
|
|
disabled={disabled || !channel.websiteUrl}
|
|
className="px-3.5 py-2 rounded-lg border border-green-200 bg-green-50 text-green-700 text-sm font-semibold transition hover:bg-green-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{disabled ? 'Importing…' : 'Import'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Businesses() {
|
|
const navigate = useNavigate();
|
|
const { setActiveBusiness } = useBusiness();
|
|
const [businesses, setBusinesses] = useState([]);
|
|
const [salesChannels, setSalesChannels] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading');
|
|
const [salesChannelsError, setSalesChannelsError] = useState('');
|
|
const [salesChannelQuery, setSalesChannelQuery] = useState('');
|
|
const [selectingBusinessId, setSelectingBusinessId] = useState('');
|
|
const [creatingSalesChannelId, setCreatingSalesChannelId] = useState('');
|
|
const [createdBusiness, setCreatedBusiness] = useState(null);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
const configuredApplicationIds = useMemo(() => (
|
|
new Set(
|
|
businesses
|
|
.map((business) => String(business?.applicationId || '').trim())
|
|
.filter(Boolean)
|
|
)
|
|
), [businesses]);
|
|
|
|
const availableSalesChannels = useMemo(() => (
|
|
salesChannels.filter((channel) => !configuredApplicationIds.has(getChannelId(channel)))
|
|
), [configuredApplicationIds, salesChannels]);
|
|
|
|
const filteredSalesChannels = useMemo(() => {
|
|
const query = salesChannelQuery.trim().toLowerCase();
|
|
if (!query) return availableSalesChannels;
|
|
|
|
return availableSalesChannels.filter((channel) => {
|
|
const name = String(getBusinessName(channel) || '').toLowerCase();
|
|
const domain = String(getBusinessDomain(channel) || '').toLowerCase();
|
|
return name.includes(query) || domain.includes(query);
|
|
});
|
|
}, [availableSalesChannels, salesChannelQuery]);
|
|
|
|
const loadBusinesses = useCallback(async () => {
|
|
const res = await apiClient.get('/api/businesses');
|
|
setBusinesses(res.data.businesses || []);
|
|
}, []);
|
|
|
|
const loadSalesChannels = useCallback(async () => {
|
|
setSalesChannelsStatus('loading');
|
|
const channels = await fetchActiveSalesChannels();
|
|
setSalesChannels(channels);
|
|
setSalesChannelsStatus('success');
|
|
}, []);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
setSalesChannelsError('');
|
|
|
|
try {
|
|
const [businessesRes, salesChannelsRes] = await Promise.allSettled([
|
|
loadBusinesses(),
|
|
loadSalesChannels(),
|
|
]);
|
|
|
|
if (businessesRes.status === 'rejected') {
|
|
setError('Failed to load businesses');
|
|
}
|
|
|
|
if (salesChannelsRes.status === 'rejected') {
|
|
setSalesChannels([]);
|
|
setSalesChannelsStatus('error');
|
|
setSalesChannelsError(
|
|
salesChannelsRes.reason?.message || 'Active sales channels could not be loaded right now.'
|
|
);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [loadBusinesses, loadSalesChannels]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
async function handleSelect(biz) {
|
|
setSelectingBusinessId(biz.businessId);
|
|
setError('');
|
|
try {
|
|
const setupComplete = await setActiveBusiness(biz);
|
|
navigate(`/${biz.businessId}/${setupComplete ? 'events' : 'global-sms'}`);
|
|
} catch (err) {
|
|
setError(err.response?.data?.error || 'Failed to open business');
|
|
} finally {
|
|
setSelectingBusinessId('');
|
|
}
|
|
}
|
|
|
|
async function handleCreateFromSalesChannel(channel) {
|
|
const applicationId = getChannelId(channel);
|
|
if (!applicationId) return;
|
|
|
|
if (!channel.websiteUrl) {
|
|
setSalesChannelsError('A website URL could not be derived from this sales channel. Please use Add Business and enter the URL manually.');
|
|
return;
|
|
}
|
|
|
|
setCreatingSalesChannelId(applicationId);
|
|
setError('');
|
|
setSalesChannelsError('');
|
|
|
|
try {
|
|
const res = await apiClient.post('/api/businesses', {
|
|
applicationId,
|
|
websiteUrl: channel.websiteUrl,
|
|
});
|
|
setCreatedBusiness(res.data);
|
|
await Promise.all([loadBusinesses(), loadSalesChannels()]);
|
|
setSalesChannelsStatus('success');
|
|
} catch (err) {
|
|
setError(err.response?.data?.error || 'Failed to add business from sales channel');
|
|
} finally {
|
|
setCreatingSalesChannelId('');
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!deleteTarget) return;
|
|
setDeleting(true);
|
|
try {
|
|
await apiClient.delete(`/api/businesses/${deleteTarget.businessId}`);
|
|
setDeleteTarget(null);
|
|
await load();
|
|
} catch (err) {
|
|
setError(err.response?.data?.error || 'Failed to delete business');
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
<div className="w-8 h-8 border-2 border-indigo-200 border-t-indigo-600 rounded-full animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 p-8">
|
|
<div className="max-w-5xl mx-auto">
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">
|
|
{businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business'}
|
|
</h1>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Import from an active sales channel when available, or use the website URL fallback to scrape manually.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowModal(true)}
|
|
className="px-4 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold shadow-sm transition"
|
|
>
|
|
+ Add Business
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-6 px-4 py-3 rounded-md bg-red-50 border border-red-200 text-red-700 font-medium text-sm flex items-center justify-between">
|
|
{error}
|
|
<button onClick={() => setError('')} className="text-red-500 hover:text-red-700 font-bold">×</button>
|
|
</div>
|
|
)}
|
|
|
|
<section className="mb-10">
|
|
<div className="mb-4">
|
|
<h2 className="text-lg font-bold text-gray-900 tracking-tight">Configured Businesses</h2>
|
|
<p className="text-sm text-gray-500 mt-1">Select a business to manage its SMS templates.</p>
|
|
</div>
|
|
|
|
{businesses.length > 0 ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{businesses.map((biz) => (
|
|
<div
|
|
key={biz.businessId}
|
|
className="group rounded-xl bg-white border border-gray-200 shadow-sm hover:border-indigo-300 hover:ring-1 hover:ring-indigo-300 transition-all overflow-hidden"
|
|
>
|
|
<button
|
|
className="w-full text-left p-6"
|
|
onClick={() => handleSelect(biz)}
|
|
disabled={selectingBusinessId === biz.businessId}
|
|
>
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-16 h-16 rounded-2xl overflow-hidden bg-gray-50 border border-gray-200 shrink-0 flex items-center justify-center shadow-sm">
|
|
{getBusinessImage(biz) ? (
|
|
<img src={getBusinessImage(biz)} alt={getBusinessName(biz)} className="w-full h-full object-cover" />
|
|
) : (
|
|
<span className="text-lg font-bold text-indigo-600">
|
|
{getBusinessName(biz)?.[0]?.toUpperCase() || 'B'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-bold text-gray-900 truncate">{getBusinessName(biz)}</p>
|
|
{getBusinessDomain(biz) && (
|
|
<p className="text-xs text-gray-500 font-medium truncate mt-1">{getBusinessDomain(biz)}</p>
|
|
)}
|
|
{getBusinessTagline(biz) && (
|
|
<p className="text-sm text-gray-600 mt-3 leading-relaxed line-clamp-2">{getBusinessTagline(biz)}</p>
|
|
)}
|
|
<p className="text-xs text-gray-400 font-medium mt-3">
|
|
Added {new Date(biz.createdAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
|
|
</p>
|
|
{selectingBusinessId === biz.businessId && (
|
|
<div className="mt-3 inline-flex items-center gap-2 text-xs text-indigo-600 font-semibold">
|
|
<span className="w-3.5 h-3.5 border-2 border-indigo-200 border-t-indigo-600 rounded-full animate-spin" />
|
|
Opening…
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
<div className="px-6 py-3 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
|
|
<span className="text-xs text-indigo-600 font-semibold group-hover:underline">Click to manage →</span>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setDeleteTarget(biz); }}
|
|
className="text-xs text-red-500 hover:text-red-700 font-medium transition"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-xl border border-dashed border-gray-300 bg-white p-8 text-center shadow-sm">
|
|
<p className="text-gray-900 font-semibold mb-1">No configured businesses yet.</p>
|
|
<p className="text-sm text-gray-500">Import from an active sales channel below, or use Add Business to enter a storefront URL manually.</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section>
|
|
<div className="flex items-end justify-between gap-4 mb-4">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-gray-900 tracking-tight">Active Sales Channels</h2>
|
|
<p className="text-sm text-gray-500 mt-1">These are pulled directly from Commerce and can be scraped into businesses with one click.</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowModal(true)}
|
|
className="text-sm font-semibold text-indigo-600 hover:text-indigo-700 transition"
|
|
>
|
|
Use website URL fallback
|
|
</button>
|
|
</div>
|
|
|
|
{salesChannelsError && (
|
|
<div className="mb-4 px-4 py-3 rounded-md bg-amber-50 border border-amber-200 text-amber-800 font-medium text-sm">
|
|
{salesChannelsError}
|
|
</div>
|
|
)}
|
|
|
|
{salesChannelsStatus === 'loading' ? (
|
|
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center shadow-sm">
|
|
<div className="w-8 h-8 border-2 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mx-auto mb-3" />
|
|
<p className="text-sm text-gray-500 font-medium">Loading active sales channels…</p>
|
|
</div>
|
|
) : availableSalesChannels.length > 0 ? (
|
|
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Search Sales Channels</label>
|
|
<input
|
|
type="text"
|
|
value={salesChannelQuery}
|
|
onChange={(e) => setSalesChannelQuery(e.target.value)}
|
|
placeholder="Search by channel name or domain"
|
|
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{filteredSalesChannels.map((channel) => {
|
|
const channelId = getChannelId(channel);
|
|
return (
|
|
<SalesChannelCard
|
|
key={channelId}
|
|
channel={channel}
|
|
disabled={creatingSalesChannelId === channelId}
|
|
onImport={() => handleCreateFromSalesChannel(channel)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{filteredSalesChannels.length === 0 && (
|
|
<div className="mt-4 rounded-xl border border-dashed border-gray-300 bg-gray-50 p-6 text-center">
|
|
<p className="text-sm font-semibold text-gray-900">No active sales channels matched your search.</p>
|
|
<p className="text-sm text-gray-500 mt-1">Use the website URL fallback if you want to scrape a storefront directly.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-xl border border-dashed border-gray-300 bg-white p-8 text-center shadow-sm">
|
|
<p className="text-gray-900 font-semibold mb-1">No active sales channels are available right now.</p>
|
|
<p className="text-sm text-gray-500">Use Add Business to enter a website URL manually and keep moving.</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
{showModal && (
|
|
<RegisterBusinessModal onClose={() => { setShowModal(false); loadBusinesses(); }} />
|
|
)}
|
|
{createdBusiness && <BusinessCreatedModal business={createdBusiness} onClose={() => setCreatedBusiness(null)} />}
|
|
{deleteTarget && (
|
|
<DeleteConfirmModal
|
|
businessName={getBusinessName(deleteTarget)}
|
|
onCancel={() => setDeleteTarget(null)}
|
|
onConfirm={handleDelete}
|
|
deleting={deleting}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|