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 (
đź—‘
Delete Business?
This will permanently delete {businessName} and all its events, templates, and images. This cannot be undone.
Cancel
{deleting ? <> Deleting…> : 'Yes, Delete'}
);
}
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 (
{isFailed ? 'Onboarding failed' : isCompleted ? 'Business ready' : 'Setting up business'}
{stageMeta.label}
{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}
{!isFailed && (
<>
Status: {status || 'pending'}
{discoveredPages > 0 ? (
{processedPages} / {discoveredPages} pages
) : (
Preparing crawl
)}
Links
{progress.linkCount}
Images
{progress.imageCount}
>
)}
{isFailed && (
{errorMessage || 'Business onboarding failed.'}
)}
{isFailed ? (
Close
) : (
Working…
)}
);
}
function StatusBadge({ status }) {
const isScraped = status === 'scraped';
return (
{isScraped ? 'Scraped' : 'Not Scraped Yet'}
);
}
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 (
{image ? (
) : (
{name?.[0]?.toUpperCase() || 'B'}
)}
{name}
{domain && (
{domain}
)}
{tagline && (
{tagline}
)}
{isScraped && item.business?.createdAt && (
Added {new Date(item.business.createdAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
)}
{!isScraped && !hasWebsiteUrl && (
A website URL could not be derived automatically for this sales channel.
)}
{isScraped ? (
<>
{
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
{
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'}
>
) : (
<>
{
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 →'}
{hasWebsiteUrl ? 'Ready to onboard' : 'Needs manual URL'}
>
)}
);
}
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 (
);
}
return (
{showUnifiedSalesChannelView ? 'Your Sales Channels' : (businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business')}
{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.'}
{!showUnifiedSalesChannelView && (
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
)}
{error && (
{error}
setError('')} className="text-gray-600 hover:text-gray-700 font-bold">×
)}
{showUnifiedSalesChannelView ? (
Sales Channels
Scraped businesses and active sales channels are shown together here.
{unifiedEntries.length > 0 ? (
Search Sales Channels
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"
/>
{filteredUnifiedEntries.map((item) => (
setShowModal(true)}
onReview={handleOpenReview}
/>
))}
{filteredUnifiedEntries.length === 0 && (
No sales channels matched your search.
Try a different name or domain.
)}
) : (
No sales channels are available yet.
Use the manual fallback only if you need to set up a storefront URL directly.
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
)}
) : (
Configured Businesses
Select a business to manage its SMS templates.
{businesses.length > 0 ? (
{businesses.map((biz) => (
setShowModal(true)}
onReview={handleOpenReview}
/>
))}
) : (
No configured businesses yet.
Use Add Business to enter a storefront URL and get started.
)}
)}
{showModal && (
{ setShowModal(false); load(); }}
onJobStarted={handleBusinessJobStarted}
/>
)}
{onboardingJob && showOnboardingModal && (
setShowOnboardingModal(false)}
/>
)}
{createdBusiness && (
setCreatedBusiness(null)}
/>
)}
{reviewBusiness && (
setReviewBusiness(null)}
/>
)}
{deleteTarget && (
setDeleteTarget(null)}
onConfirm={handleDelete}
deleting={deleting}
/>
)}
);
}