768 lines
30 KiB
JavaScript
768 lines
30 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 {
|
||
getApplicationId,
|
||
getBusinessDomain,
|
||
getBusinessImage,
|
||
getBusinessName,
|
||
getBusinessTagline,
|
||
} from '../utils/businessProfile';
|
||
|
||
function normalizeText(value) {
|
||
return typeof value === 'string' ? value.trim() : '';
|
||
}
|
||
|
||
function normalizeUniqueStrings(value) {
|
||
if (!Array.isArray(value)) return [];
|
||
|
||
const seen = new Set();
|
||
return value
|
||
.map((entry) => normalizeText(entry))
|
||
.filter((entry) => {
|
||
if (!entry || seen.has(entry)) return false;
|
||
seen.add(entry);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function extractCdnUrls(business) {
|
||
return normalizeUniqueStrings(business?.relevantImagePaths);
|
||
}
|
||
|
||
function normalizeScrapeLinks(value) {
|
||
if (!Array.isArray(value)) return [];
|
||
|
||
const seen = new Set();
|
||
return value
|
||
.map((entry) => {
|
||
if (typeof entry === 'string') {
|
||
const href = normalizeText(entry);
|
||
return href ? { href, label: href } : null;
|
||
}
|
||
|
||
if (!entry || typeof entry !== 'object') return null;
|
||
|
||
const href = normalizeText(entry.href || entry.url || entry.link);
|
||
if (!href) return null;
|
||
|
||
const label = normalizeText(entry.text || entry.title || entry.label || href);
|
||
return { href, label };
|
||
})
|
||
.filter((entry) => {
|
||
if (!entry || seen.has(entry.href)) return false;
|
||
seen.add(entry.href);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function formatPrettyJson(value) {
|
||
if (value == null) return '';
|
||
|
||
if (typeof value === 'string') {
|
||
try {
|
||
return JSON.stringify(JSON.parse(value), null, 2);
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
try {
|
||
return JSON.stringify(value, null, 2);
|
||
} catch {
|
||
return String(value);
|
||
}
|
||
}
|
||
|
||
function CdnGallery({ urls, compact = false, showLabels = true, clickable = true }) {
|
||
if (!urls.length) return null;
|
||
|
||
return (
|
||
<div className={`grid gap-3 ${compact ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3'}`}>
|
||
{urls.map((url, index) => {
|
||
const Wrapper = clickable ? 'a' : 'div';
|
||
const wrapperProps = clickable
|
||
? { href: url, target: '_blank', rel: 'noreferrer' }
|
||
: {};
|
||
|
||
return (
|
||
<Wrapper
|
||
key={`${url}-${index}`}
|
||
{...wrapperProps}
|
||
className={`group overflow-hidden rounded-xl border border-gray-200 bg-white transition ${clickable ? 'hover:border-primary-blue' : ''}`}
|
||
>
|
||
<div className={`bg-gray-50 ${compact ? 'aspect-[4/3]' : 'aspect-[5/4]'}`}>
|
||
<img
|
||
src={url}
|
||
alt={`Storefront image ${index + 1}`}
|
||
className="h-full w-full object-cover"
|
||
onError={(event) => {
|
||
event.currentTarget.style.opacity = '0.35';
|
||
}}
|
||
/>
|
||
</div>
|
||
{showLabels && (
|
||
<div className="border-t border-gray-100 px-3 py-2">
|
||
<p className="text-xs text-gray-500 break-all leading-relaxed line-clamp-3">{url}</p>
|
||
</div>
|
||
)}
|
||
</Wrapper>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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-lg p-5 w-full max-w-md ">
|
||
<div className="w-12 h-12 rounded-full bg-white flex items-center justify-center mx-auto mb-4">
|
||
<span className="text-xl">🗑</span>
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-gray-800 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-800 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 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 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition 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);
|
||
const cdnUrls = extractCdnUrls(business?.scrapeArtifacts?.cdnUrls?.length ? { relevantImagePaths: business.scrapeArtifacts.cdnUrls } : business);
|
||
const links = normalizeScrapeLinks(business?.scrapeArtifacts?.links);
|
||
const prettyJson = useMemo(() => formatPrettyJson(business?.scrapeArtifacts?.json), [business]);
|
||
|
||
useEffect(() => {
|
||
const previousBodyOverflow = document.body.style.overflow;
|
||
const previousBodyOverscroll = document.body.style.overscrollBehavior;
|
||
const previousHtmlOverflow = document.documentElement.style.overflow;
|
||
const previousHtmlOverscroll = document.documentElement.style.overscrollBehavior;
|
||
|
||
document.body.style.overflow = 'hidden';
|
||
document.body.style.overscrollBehavior = 'none';
|
||
document.documentElement.style.overflow = 'hidden';
|
||
document.documentElement.style.overscrollBehavior = 'none';
|
||
|
||
return () => {
|
||
document.body.style.overflow = previousBodyOverflow;
|
||
document.body.style.overscrollBehavior = previousBodyOverscroll;
|
||
document.documentElement.style.overflow = previousHtmlOverflow;
|
||
document.documentElement.style.overscrollBehavior = previousHtmlOverscroll;
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
|
||
<div
|
||
className="flex h-full w-full flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl"
|
||
style={{ width: '920px', maxWidth: 'calc(100vw - 2rem)', height: 'min(78vh, 760px)' }}
|
||
>
|
||
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-gray-200 px-6 py-5">
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Business created</p>
|
||
<h2 className="mt-2 text-2xl font-semibold text-gray-900">{name}</h2>
|
||
<p className="mt-1 text-sm text-gray-500">
|
||
{domain
|
||
? `Scrape completed for ${domain}. Review the captured assets below before moving on.`
|
||
: 'Scrape completed. Review the captured assets below before moving on.'}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-50 hover:text-gray-900"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-5">
|
||
<div className="space-y-5 pb-2">
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||
<div className="flex items-start gap-4">
|
||
<div className="w-16 h-16 rounded-xl overflow-hidden bg-white 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-xl font-bold text-primary-blue">{name?.[0]?.toUpperCase() || 'B'}</span>
|
||
)}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-lg font-semibold tracking-tight text-gray-900">{name}</p>
|
||
{domain && <p className="mt-1 text-sm font-medium text-gray-500 break-all">{domain}</p>}
|
||
{tagline && <p className="mt-2 text-sm leading-relaxed text-gray-700">{tagline}</p>}
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-gray-600 border border-gray-200">
|
||
{cdnUrls.length} image{cdnUrls.length === 1 ? '' : 's'}
|
||
</span>
|
||
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-gray-600 border border-gray-200">
|
||
{links.length} link{links.length === 1 ? '' : 's'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{cdnUrls.length > 0 && (
|
||
<section className="space-y-3">
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Images</p>
|
||
<p className="mt-1 text-sm text-gray-500">Captured storefront images are available below.</p>
|
||
</div>
|
||
<CdnGallery urls={cdnUrls} compact showLabels={false} />
|
||
</section>
|
||
)}
|
||
|
||
{prettyJson && (
|
||
<section className="space-y-3">
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Captured Data</p>
|
||
<p className="mt-1 text-sm text-gray-500">Raw storefront data captured during onboarding.</p>
|
||
</div>
|
||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-950">
|
||
<pre className="max-h-56 overflow-auto px-4 py-4 text-xs leading-relaxed text-gray-100">
|
||
{prettyJson}
|
||
</pre>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{links.length > 0 && (
|
||
<section className="space-y-3">
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Links</p>
|
||
<p className="mt-1 text-sm text-gray-500">Every discovered storefront link is available below.</p>
|
||
</div>
|
||
<div className="rounded-xl border border-gray-200">
|
||
<div className="max-h-72 overflow-y-auto divide-y divide-gray-100">
|
||
{links.map((link, index) => (
|
||
<a
|
||
key={`${link.href}-${index}`}
|
||
href={link.href}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="block px-4 py-3 transition hover:bg-gray-50"
|
||
>
|
||
<p className="text-sm font-medium text-gray-800 break-all">{link.label}</p>
|
||
<p className="mt-1 text-xs text-primary-blue break-all">{link.href}</p>
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0 border-t border-gray-200 px-6 py-4">
|
||
<button
|
||
onClick={onClose}
|
||
className="w-full rounded-lg bg-primary-blue py-2 text-sm font-medium text-white transition hover:bg-primary-dark"
|
||
>
|
||
Continue
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatusBadge({ status }) {
|
||
const isScraped = status === 'scraped';
|
||
|
||
return (
|
||
<span
|
||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${isScraped
|
||
? 'bg-green-100 text-green-700'
|
||
: 'bg-amber-100 text-amber-700'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`h-2 w-2 rounded-full ${isScraped ? 'bg-green-500' : 'bg-amber-500'
|
||
}`}
|
||
/>
|
||
{isScraped ? 'Scraped' : 'Not Scraped Yet'}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function UnifiedBusinessCard({
|
||
item,
|
||
selectingBusinessId,
|
||
creatingSalesChannelId,
|
||
onSelect,
|
||
onImport,
|
||
onDelete,
|
||
onFallback,
|
||
}) {
|
||
const entity = item.business || item.channel;
|
||
const businessId = item.business?.businessId || '';
|
||
const channelId = getApplicationId(item.channel);
|
||
const image = getBusinessImage(entity);
|
||
const name = getBusinessName(entity);
|
||
const domain = getBusinessDomain(entity);
|
||
const tagline = getBusinessTagline(entity);
|
||
const isScraped = item.status === 'scraped';
|
||
const cdnUrls = extractCdnUrls(item.business);
|
||
const isOpening = isScraped && selectingBusinessId === businessId;
|
||
const isImporting = !isScraped && creatingSalesChannelId === channelId;
|
||
const hasWebsiteUrl = Boolean(item.channel?.websiteUrl);
|
||
const canOpenBusiness = isScraped && item.business && !isOpening;
|
||
|
||
function handleCardClick() {
|
||
if (!canOpenBusiness) return;
|
||
onSelect(item.business);
|
||
}
|
||
|
||
function handleCardKeyDown(event) {
|
||
if (!canOpenBusiness) return;
|
||
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
onSelect(item.business);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={`group rounded-lg bg-white border border-gray-200 transition-all overflow-hidden ${isScraped ? 'cursor-pointer hover:border-primary-blue hover:shadow-sm' : 'hover:border-primary-blue'}`}
|
||
onClick={handleCardClick}
|
||
onKeyDown={handleCardKeyDown}
|
||
role={isScraped ? 'button' : undefined}
|
||
tabIndex={isScraped ? 0 : undefined}
|
||
aria-label={isScraped ? `Open ${name}` : undefined}
|
||
>
|
||
<div className="p-5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex items-start gap-4 min-w-0">
|
||
<div className="w-16 h-16 rounded-lg overflow-hidden bg-white 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-lg font-bold text-primary-blue">
|
||
{name?.[0]?.toUpperCase() || 'B'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="font-bold text-gray-800 truncate">{name}</p>
|
||
{domain && (
|
||
<p className="text-xs text-gray-500 font-medium truncate mt-1">{domain}</p>
|
||
)}
|
||
{tagline && (
|
||
<p className="text-sm text-gray-600 mt-3 leading-relaxed line-clamp-2">{tagline}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<StatusBadge status={item.status} />
|
||
</div>
|
||
|
||
{isScraped && item.business?.createdAt && (
|
||
<p className="text-xs text-gray-400 font-medium mt-4">
|
||
Added {new Date(item.business.createdAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||
</p>
|
||
)}
|
||
|
||
{!isScraped && !hasWebsiteUrl && (
|
||
<p className="text-xs text-amber-700 font-medium mt-4">
|
||
A website URL could not be derived automatically for this sales channel.
|
||
</p>
|
||
)}
|
||
|
||
{isScraped && cdnUrls.length > 0 && (
|
||
<div className="mt-4 border-t border-gray-100 pt-4">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Images</p>
|
||
<span className="text-xs font-medium text-gray-400">
|
||
{cdnUrls.length} image{cdnUrls.length === 1 ? '' : 's'}
|
||
</span>
|
||
</div>
|
||
<CdnGallery urls={cdnUrls.slice(0, 6)} compact showLabels={false} clickable={false} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="px-5 py-3 bg-white border-t border-gray-100 flex items-center justify-between gap-3">
|
||
{isScraped ? (
|
||
<>
|
||
<button
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onDelete(item.business);
|
||
}}
|
||
className="rounded-md px-2.5 py-1.5 text-xs font-medium text-gray-600 transition hover:bg-red-50 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-200"
|
||
>
|
||
Delete
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button
|
||
onClick={() => {
|
||
if (hasWebsiteUrl) {
|
||
onImport(item.channel);
|
||
return;
|
||
}
|
||
onFallback();
|
||
}}
|
||
disabled={isImporting}
|
||
className="text-sm text-primary-blue font-semibold group-hover:underline disabled:opacity-60"
|
||
>
|
||
{isImporting ? 'Scraping…' : hasWebsiteUrl ? 'Scrape →' : 'Use Fallback URL →'}
|
||
</button>
|
||
<span className="text-xs text-gray-500 font-medium">
|
||
{hasWebsiteUrl ? 'Ready to scrape' : 'Needs manual URL'}
|
||
</span>
|
||
</>
|
||
)}
|
||
</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 [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 showUnifiedSalesChannelView = salesChannelsStatus === 'success';
|
||
|
||
const unifiedEntries = useMemo(() => {
|
||
const matchedBusinessIds = new Set();
|
||
const businessByApplicationId = new Map(
|
||
businesses
|
||
.map((business) => [getApplicationId(business), business])
|
||
.filter(([applicationId]) => Boolean(applicationId))
|
||
);
|
||
|
||
const mergedEntries = salesChannels.map((channel, index) => {
|
||
const applicationId = getApplicationId(channel);
|
||
const business = applicationId ? businessByApplicationId.get(applicationId) || null : null;
|
||
|
||
if (business?.businessId) {
|
||
matchedBusinessIds.add(business.businessId);
|
||
}
|
||
|
||
return {
|
||
key: `channel:${applicationId || channel.name || channel.domain || index}`,
|
||
status: business ? 'scraped' : 'not_scraped',
|
||
applicationId,
|
||
business,
|
||
channel,
|
||
};
|
||
});
|
||
|
||
const standaloneBusinesses = businesses
|
||
.filter((business) => !matchedBusinessIds.has(business.businessId))
|
||
.map((business) => ({
|
||
key: `business:${business.businessId}`,
|
||
status: 'scraped',
|
||
applicationId: getApplicationId(business),
|
||
business,
|
||
channel: null,
|
||
}));
|
||
|
||
return [...mergedEntries, ...standaloneBusinesses].sort((left, right) => {
|
||
if (left.status !== right.status) {
|
||
return left.status === 'not_scraped' ? -1 : 1;
|
||
}
|
||
|
||
return getBusinessName(left.business || left.channel)
|
||
.localeCompare(getBusinessName(right.business || right.channel));
|
||
});
|
||
}, [businesses, salesChannels]);
|
||
|
||
const filteredUnifiedEntries = useMemo(() => {
|
||
const query = salesChannelQuery.trim().toLowerCase();
|
||
if (!query) return unifiedEntries;
|
||
|
||
return unifiedEntries.filter((entry) => {
|
||
const entity = entry.business || entry.channel;
|
||
const name = String(getBusinessName(entity) || '').toLowerCase();
|
||
const domain = String(getBusinessDomain(entity) || '').toLowerCase();
|
||
const tagline = String(getBusinessTagline(entity) || '').toLowerCase();
|
||
return name.includes(query) || domain.includes(query) || tagline.includes(query);
|
||
});
|
||
}, [unifiedEntries, 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('');
|
||
|
||
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');
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [loadBusinesses, loadSalesChannels]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
async function handleBusinessCreated(created) {
|
||
setShowModal(false);
|
||
setCreatedBusiness(created);
|
||
|
||
try {
|
||
await Promise.all([loadBusinesses(), loadSalesChannels()]);
|
||
setSalesChannelsStatus('success');
|
||
} catch (err) {
|
||
setError(err.response?.data?.error || 'Business was created, but the business list could not be refreshed.');
|
||
}
|
||
}
|
||
|
||
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 = getApplicationId(channel);
|
||
if (!applicationId) return;
|
||
|
||
if (!channel.websiteUrl) {
|
||
setError('A website URL could not be derived from this sales channel. Please use the fallback URL flow to continue.');
|
||
return;
|
||
}
|
||
|
||
setCreatingSalesChannelId(applicationId);
|
||
setError('');
|
||
|
||
try {
|
||
const res = await apiClient.post('/api/businesses', {
|
||
applicationId,
|
||
websiteUrl: channel.websiteUrl,
|
||
});
|
||
await handleBusinessCreated(res.data);
|
||
} 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-white">
|
||
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-white p-5">
|
||
<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-800 tracking-tight">
|
||
{showUnifiedSalesChannelView ? 'Your Sales Channels' : (businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business')}
|
||
</h1>
|
||
<p className="text-sm text-gray-500 mt-1">
|
||
{showUnifiedSalesChannelView
|
||
? 'View every connected sales channel in one place and scrape the ones that are not onboarded yet.'
|
||
: 'Add a storefront URL and we’ll scrape it to set up your business.'}
|
||
</p>
|
||
</div>
|
||
{!showUnifiedSalesChannelView && (
|
||
<button
|
||
onClick={() => setShowModal(true)}
|
||
className="px-4 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition"
|
||
>
|
||
+ Add Business
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mb-6 px-4 py-2 rounded-md bg-white border border-gray-200 text-gray-700 font-medium text-sm flex items-center justify-between">
|
||
{error}
|
||
<button onClick={() => setError('')} className="text-gray-600 hover:text-gray-700 font-bold">×</button>
|
||
</div>
|
||
)}
|
||
|
||
{showUnifiedSalesChannelView ? (
|
||
<section>
|
||
<div className="mb-4">
|
||
<h2 className="text-lg font-bold text-gray-800 tracking-tight">Sales Channels</h2>
|
||
<p className="text-sm text-gray-500 mt-1">Scraped businesses and active sales channels are shown together here.</p>
|
||
</div>
|
||
|
||
{unifiedEntries.length > 0 ? (
|
||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||
<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 name, domain, or description"
|
||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
{filteredUnifiedEntries.map((item) => (
|
||
<UnifiedBusinessCard
|
||
key={item.key}
|
||
item={item}
|
||
selectingBusinessId={selectingBusinessId}
|
||
creatingSalesChannelId={creatingSalesChannelId}
|
||
onSelect={handleSelect}
|
||
onImport={handleCreateFromSalesChannel}
|
||
onDelete={setDeleteTarget}
|
||
onFallback={() => setShowModal(true)}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{filteredUnifiedEntries.length === 0 && (
|
||
<div className="mt-4 rounded-lg border border-dashed border-gray-300 bg-white p-5 text-center">
|
||
<p className="text-sm font-semibold text-gray-800">No sales channels matched your search.</p>
|
||
<p className="text-sm text-gray-500 mt-1">Try a different name or domain.</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-dashed border-gray-300 bg-white p-5 text-center">
|
||
<p className="text-gray-800 font-semibold mb-1">No sales channels are available yet.</p>
|
||
<p className="text-sm text-gray-500 mb-4">Use the manual fallback only if you need to set up a storefront URL directly.</p>
|
||
<button
|
||
onClick={() => setShowModal(true)}
|
||
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition"
|
||
>
|
||
Use website URL fallback
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
) : (
|
||
<section className="mb-10">
|
||
<div className="mb-4">
|
||
<h2 className="text-lg font-bold text-gray-800 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) => (
|
||
<UnifiedBusinessCard
|
||
key={`fallback:${biz.businessId}`}
|
||
item={{ key: `fallback:${biz.businessId}`, status: 'scraped', business: biz, channel: null }}
|
||
selectingBusinessId={selectingBusinessId}
|
||
creatingSalesChannelId={creatingSalesChannelId}
|
||
onSelect={handleSelect}
|
||
onImport={handleCreateFromSalesChannel}
|
||
onDelete={setDeleteTarget}
|
||
onFallback={() => setShowModal(true)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-dashed border-gray-300 bg-white p-5 text-center">
|
||
<p className="text-gray-800 font-semibold mb-1">No configured businesses yet.</p>
|
||
<p className="text-sm text-gray-500">Use Add Business to enter a storefront URL and get started.</p>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
|
||
{showModal && (
|
||
<RegisterBusinessModal
|
||
onClose={() => { setShowModal(false); load(); }}
|
||
onSuccess={handleBusinessCreated}
|
||
/>
|
||
)}
|
||
{createdBusiness && <BusinessCreatedModal business={createdBusiness} onClose={() => setCreatedBusiness(null)} />}
|
||
{deleteTarget && (
|
||
<DeleteConfirmModal
|
||
businessName={getBusinessName(deleteTarget)}
|
||
onCancel={() => setDeleteTarget(null)}
|
||
onConfirm={handleDelete}
|
||
deleting={deleting}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|