748 lines
28 KiB
JavaScript
748 lines
28 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 BusinessReviewModal from '../components/BusinessReviewModal';
|
||
import { fetchActiveSalesChannels } from '../utils/fyndSalesChannels';
|
||
import {
|
||
fetchBusinessOnboardingJob,
|
||
getBusinessOnboardingError,
|
||
getBusinessOnboardingProgress,
|
||
getBusinessOnboardingStageMeta,
|
||
shouldRetryMissingBusinessOnboardingJob,
|
||
startBusinessOnboardingJob,
|
||
} from '../utils/businessOnboarding';
|
||
import {
|
||
getApplicationId,
|
||
getBusinessDomain,
|
||
getBusinessImage,
|
||
getBusinessName,
|
||
getBusinessTagline,
|
||
} from '../utils/businessProfile';
|
||
|
||
function normalizeText(value) {
|
||
return typeof value === 'string' ? value.trim() : '';
|
||
}
|
||
|
||
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 BusinessOnboardingModal({ job, onClose }) {
|
||
const status = normalizeText(job?.status);
|
||
const stageMeta = getBusinessOnboardingStageMeta(job?.stage || status);
|
||
const progress = getBusinessOnboardingProgress(job);
|
||
const isFailed = status === 'failed';
|
||
const isCompleted = status === 'completed';
|
||
const discoveredPages = progress.pagesDiscovered;
|
||
const processedPages = progress.pagesProcessed;
|
||
const progressWidth = stageMeta.percent;
|
||
const errorMessage = getBusinessOnboardingError(job);
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
|
||
<div className="w-full max-w-lg rounded-2xl border border-gray-200 bg-white shadow-xl">
|
||
<div className="border-b border-gray-200 px-6 py-5">
|
||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">
|
||
{isFailed ? 'Onboarding failed' : isCompleted ? 'Business ready' : 'Setting up business'}
|
||
</p>
|
||
<h2 className="mt-2 text-2xl font-semibold text-gray-900">{stageMeta.label}</h2>
|
||
<p className="mt-1 text-sm text-gray-500">
|
||
{isFailed
|
||
? 'The onboarding job could not be completed. You can close this dialog and try again.'
|
||
: isCompleted
|
||
? 'The storefront crawl and brand analysis finished successfully.'
|
||
: stageMeta.note}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-5 px-6 py-5">
|
||
{!isFailed && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
|
||
<div
|
||
className={`h-full rounded-full transition-all ${isCompleted ? 'bg-green-500' : 'bg-primary-blue'}`}
|
||
style={{ width: `${progressWidth}%` }}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between text-xs font-medium text-gray-500">
|
||
<span>Status: {status || 'pending'}</span>
|
||
{discoveredPages > 0 ? (
|
||
<span>{processedPages} / {discoveredPages} pages</span>
|
||
) : (
|
||
<span>Preparing crawl</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-400">Pages</p>
|
||
<p className="mt-2 text-lg font-semibold text-gray-900">{processedPages}</p>
|
||
</div>
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-400">Links</p>
|
||
<p className="mt-2 text-lg font-semibold text-gray-900">{progress.linkCount}</p>
|
||
</div>
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-400">Images</p>
|
||
<p className="mt-2 text-lg font-semibold text-gray-900">{progress.imageCount}</p>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{isFailed && (
|
||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||
{errorMessage || 'Business onboarding failed.'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="border-t border-gray-200 px-6 py-4">
|
||
{isFailed ? (
|
||
<button
|
||
onClick={onClose}
|
||
className="w-full rounded-lg bg-primary-blue py-2 text-sm font-medium text-white transition hover:bg-primary-dark"
|
||
>
|
||
Close
|
||
</button>
|
||
) : (
|
||
<button
|
||
disabled
|
||
className="w-full rounded-lg bg-gray-100 py-2 text-sm font-medium text-gray-500"
|
||
>
|
||
Working…
|
||
</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,
|
||
reviewLoadingBusinessId,
|
||
onSelect,
|
||
onImport,
|
||
onDelete,
|
||
onFallback,
|
||
onReview,
|
||
}) {
|
||
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 isOpening = isScraped && selectingBusinessId === businessId;
|
||
const isImporting = !isScraped && creatingSalesChannelId === channelId;
|
||
const isLoadingReview = isScraped && reviewLoadingBusinessId === businessId;
|
||
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>
|
||
)}
|
||
|
||
</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={(event) => {
|
||
event.stopPropagation();
|
||
onReview(item.business);
|
||
}}
|
||
disabled={isLoadingReview}
|
||
className="rounded-md bg-primary-blue px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-primary-blue/30 disabled:opacity-60"
|
||
>
|
||
{isLoadingReview ? 'Loading review…' : 'View Brand Review'}
|
||
</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 ? 'Onboarding…' : hasWebsiteUrl ? 'Start onboarding →' : 'Use fallback URL →'}
|
||
</button>
|
||
<span className="text-xs text-gray-500 font-medium">
|
||
{hasWebsiteUrl ? 'Ready to onboard' : '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 [onboardingJob, setOnboardingJob] = useState(null);
|
||
const [showOnboardingModal, setShowOnboardingModal] = useState(false);
|
||
const [showModal, setShowModal] = useState(false);
|
||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||
const [deleting, setDeleting] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [reviewBusiness, setReviewBusiness] = useState(null);
|
||
const [reviewLoadingBusinessId, setReviewLoadingBusinessId] = useState('');
|
||
const onboardingJobCreatedAt = onboardingJob?.createdAt;
|
||
|
||
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]);
|
||
|
||
const handleBusinessCreated = useCallback(async (created) => {
|
||
setShowModal(false);
|
||
setShowOnboardingModal(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.');
|
||
}
|
||
}, [loadBusinesses, loadSalesChannels]);
|
||
|
||
const handleBusinessJobStarted = useCallback(async (job) => {
|
||
setError('');
|
||
setShowModal(false);
|
||
setCreatedBusiness(null);
|
||
setOnboardingJob(job);
|
||
setShowOnboardingModal(true);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!onboardingJob?.jobId) return undefined;
|
||
if (onboardingJob.status === 'completed') return undefined;
|
||
|
||
let cancelled = false;
|
||
let timeoutId = null;
|
||
let transientNotFoundMisses = 0;
|
||
|
||
async function pollJob() {
|
||
try {
|
||
const nextJob = await fetchBusinessOnboardingJob(onboardingJob.jobId);
|
||
if (cancelled) return;
|
||
|
||
transientNotFoundMisses = 0;
|
||
setOnboardingJob(nextJob);
|
||
|
||
if (nextJob.status === 'completed' && nextJob.business) {
|
||
await handleBusinessCreated(nextJob.business);
|
||
if (!cancelled) {
|
||
setOnboardingJob(null);
|
||
setShowOnboardingModal(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (nextJob.status === 'failed') {
|
||
setShowOnboardingModal(true);
|
||
return;
|
||
}
|
||
|
||
timeoutId = window.setTimeout(pollJob, 2200);
|
||
} catch (err) {
|
||
if (cancelled) return;
|
||
|
||
if (shouldRetryMissingBusinessOnboardingJob({ createdAt: onboardingJobCreatedAt }, err, transientNotFoundMisses)) {
|
||
transientNotFoundMisses += 1;
|
||
timeoutId = window.setTimeout(pollJob, 1800);
|
||
return;
|
||
}
|
||
|
||
setOnboardingJob((current) => ({
|
||
...(current || {}),
|
||
status: 'failed',
|
||
stage: 'failed',
|
||
error: { message: err.response?.data?.error || 'Failed to fetch onboarding progress.' },
|
||
}));
|
||
setShowOnboardingModal(true);
|
||
}
|
||
}
|
||
|
||
timeoutId = window.setTimeout(pollJob, 2500);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
if (timeoutId) window.clearTimeout(timeoutId);
|
||
};
|
||
}, [handleBusinessCreated, onboardingJob?.jobId, onboardingJob?.status, onboardingJobCreatedAt]);
|
||
|
||
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 job = await startBusinessOnboardingJob({
|
||
applicationId,
|
||
websiteUrl: channel.websiteUrl,
|
||
});
|
||
await handleBusinessJobStarted(job);
|
||
} 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);
|
||
}
|
||
}
|
||
|
||
async function handleOpenReview(business) {
|
||
if (!business?.businessId || reviewLoadingBusinessId) return;
|
||
|
||
setReviewLoadingBusinessId(business.businessId);
|
||
setError('');
|
||
|
||
try {
|
||
if (business?.scrapeArtifacts?.json) {
|
||
setReviewBusiness(business);
|
||
return;
|
||
}
|
||
|
||
const response = await apiClient.get(`/api/businesses/${business.businessId}`);
|
||
setReviewBusiness(response.data);
|
||
} catch (err) {
|
||
setError(err.response?.data?.error || 'Failed to load brand review.');
|
||
} finally {
|
||
setReviewLoadingBusinessId('');
|
||
}
|
||
}
|
||
|
||
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 onboard the ones that are not scraped yet.'
|
||
: 'Add a storefront URL and we’ll scrape the homepage, about page, and representative product pages 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}
|
||
reviewLoadingBusinessId={reviewLoadingBusinessId}
|
||
onSelect={handleSelect}
|
||
onImport={handleCreateFromSalesChannel}
|
||
onDelete={setDeleteTarget}
|
||
onFallback={() => setShowModal(true)}
|
||
onReview={handleOpenReview}
|
||
/>
|
||
))}
|
||
</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}
|
||
reviewLoadingBusinessId={reviewLoadingBusinessId}
|
||
onSelect={handleSelect}
|
||
onImport={handleCreateFromSalesChannel}
|
||
onDelete={setDeleteTarget}
|
||
onFallback={() => setShowModal(true)}
|
||
onReview={handleOpenReview}
|
||
/>
|
||
))}
|
||
</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(); }}
|
||
onJobStarted={handleBusinessJobStarted}
|
||
/>
|
||
)}
|
||
{onboardingJob && showOnboardingModal && (
|
||
<BusinessOnboardingModal
|
||
job={onboardingJob}
|
||
onClose={() => setShowOnboardingModal(false)}
|
||
/>
|
||
)}
|
||
{createdBusiness && (
|
||
<BusinessReviewModal
|
||
business={createdBusiness}
|
||
eyebrow="Business created"
|
||
helperText={createdBusiness?.domain
|
||
? `Onboarding completed for ${createdBusiness.domain}. Review the captured brand context before moving on.`
|
||
: 'Onboarding completed. Review the captured brand context before moving on.'}
|
||
closeLabel="Continue"
|
||
onClose={() => setCreatedBusiness(null)}
|
||
/>
|
||
)}
|
||
{reviewBusiness && (
|
||
<BusinessReviewModal
|
||
business={reviewBusiness}
|
||
onClose={() => setReviewBusiness(null)}
|
||
/>
|
||
)}
|
||
{deleteTarget && (
|
||
<DeleteConfirmModal
|
||
businessName={getBusinessName(deleteTarget)}
|
||
onCancel={() => setDeleteTarget(null)}
|
||
onConfirm={handleDelete}
|
||
deleting={deleting}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|