Proper API to fetch sales channel directly

This commit is contained in:
Ritul Jadhav 2026-03-31 12:02:05 +05:30
parent 52fcf8a7af
commit b8451e7df8
4 changed files with 468 additions and 102 deletions

View File

@ -1,32 +1,42 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import apiClient from '../api/client'; import apiClient from '../api/client';
import {
getBusinessDomain,
getBusinessImage,
getBusinessName,
getBusinessTagline,
getChannelId,
normalizeChannelsPayload,
} from '../utils/businessProfile';
function normalizeChannelsPayload(data) { export default function RegisterBusinessModal({
if (Array.isArray(data)) return data; onClose,
if (Array.isArray(data?.salesChannels)) return data.salesChannels; initialSalesChannels = [],
if (Array.isArray(data?.channels)) return data.channels; initialSalesChannelsStatus = 'idle',
return []; initialSalesChannelsError = '',
} }) {
function getChannelId(channel) {
return channel?.salesChannelId || channel?.id || channel?._id || '';
}
export default function RegisterBusinessModal({ onClose }) {
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [status, setStatus] = useState('idle'); // idle | loading | success | error const [status, setStatus] = useState('idle'); // idle | loading | success | error
const [brandName, setBrandName] = useState(''); const [createdBusiness, setCreatedBusiness] = useState(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [entryMode, setEntryMode] = useState('sales-channel'); const [entryMode, setEntryMode] = useState(
const [salesChannels, setSalesChannels] = useState([]); initialSalesChannelsStatus === 'error' ? 'manual' : 'sales-channel'
const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading'); // loading | success | error );
const [salesChannelsError, setSalesChannelsError] = useState(''); const [salesChannels, setSalesChannels] = useState(initialSalesChannels);
const [selectedSalesChannelId, setSelectedSalesChannelId] = useState(''); const [salesChannelsStatus, setSalesChannelsStatus] = useState(
initialSalesChannelsStatus === 'idle' ? 'loading' : initialSalesChannelsStatus
); // loading | success | error
const [salesChannelsError, setSalesChannelsError] = useState(initialSalesChannelsError);
const [selectedSalesChannelId, setSelectedSalesChannelId] = useState(() => (
initialSalesChannels[0] ? getChannelId(initialSalesChannels[0]) : ''
));
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (initialSalesChannelsStatus !== 'idle') return undefined;
async function fetchSalesChannels() { async function fetchSalesChannels() {
setSalesChannelsStatus('loading'); setSalesChannelsStatus('loading');
setSalesChannelsError(''); setSalesChannelsError('');
@ -59,7 +69,7 @@ export default function RegisterBusinessModal({ onClose }) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [initialSalesChannelsStatus]);
const filteredSalesChannels = useMemo(() => { const filteredSalesChannels = useMemo(() => {
if (!searchQuery.trim()) return salesChannels; if (!searchQuery.trim()) return salesChannels;
@ -97,7 +107,7 @@ export default function RegisterBusinessModal({ onClose }) {
: { websiteUrl: url.trim() }; : { websiteUrl: url.trim() };
const res = await apiClient.post('/api/businesses', payload); const res = await apiClient.post('/api/businesses', payload);
setBrandName(res.data.brandName); setCreatedBusiness(res.data);
setStatus('success'); setStatus('success');
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Something went wrong. Please try again.'); setError(err.response?.data?.error || 'Something went wrong. Please try again.');
@ -105,6 +115,11 @@ export default function RegisterBusinessModal({ onClose }) {
} }
} }
const successName = getBusinessName(createdBusiness);
const successDomain = getBusinessDomain(createdBusiness);
const successTagline = getBusinessTagline(createdBusiness);
const successImage = getBusinessImage(createdBusiness);
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm"> <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="bg-white border border-gray-200 rounded-xl p-8 w-full max-w-md shadow-xl">
@ -113,8 +128,29 @@ export default function RegisterBusinessModal({ onClose }) {
<div className="text-center"> <div className="text-center">
<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> <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">Business Added!</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">Business Added!</h2>
<p className="text-gray-500 text-sm mb-1 font-medium">Brand detected:</p> <p className="text-gray-500 text-sm mb-4 font-medium">Brand detected and ready for onboarding.</p>
<p className="text-indigo-600 font-bold text-lg mb-6 tracking-tight">{brandName}</p> <div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 mb-6 text-left">
<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">
{successImage ? (
<img src={successImage} alt={successName} className="w-full h-full object-cover" />
) : (
<span className="text-xl font-bold text-indigo-600">
{successName?.[0]?.toUpperCase() || 'B'}
</span>
)}
</div>
<div className="min-w-0">
<p className="text-indigo-600 font-bold text-lg tracking-tight truncate">{successName}</p>
{successDomain && (
<p className="text-sm text-gray-500 font-medium truncate mt-0.5">{successDomain}</p>
)}
{successTagline && (
<p className="text-sm text-gray-700 mt-2 leading-relaxed line-clamp-2">{successTagline}</p>
)}
</div>
</div>
</div>
<button <button
onClick={onClose} onClick={onClose}
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 shadow-sm text-white font-medium transition" className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 shadow-sm text-white font-medium transition"

View File

@ -1,8 +1,17 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import apiClient from '../api/client'; import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
import RegisterBusinessModal from '../components/RegisterBusinessModal'; import RegisterBusinessModal from '../components/RegisterBusinessModal';
import {
getBusinessDomain,
getBusinessImage,
getBusinessName,
getBusinessTagline,
getChannelId,
isChannelActive,
normalizeChannelsPayload,
} from '../utils/businessProfile';
function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) { function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
return ( return (
@ -36,30 +45,165 @@ function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
); );
} }
function BusinessSummaryCard({ entity, actionLabel, actionTone = 'indigo', onAction, disabled, footer, loadingLabel }) {
const name = getBusinessName(entity);
const domain = getBusinessDomain(entity);
const tagline = getBusinessTagline(entity);
const image = getBusinessImage(entity);
const actionClassName = actionTone === 'green'
? 'text-green-700 bg-green-50 border-green-200 hover:bg-green-100'
: 'text-indigo-700 bg-indigo-50 border-indigo-200 hover:bg-indigo-100';
return (
<div className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
<div className="p-5">
<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">
{image ? (
<img src={image} alt={name} className="w-full h-full object-cover" />
) : (
<span className="text-lg font-bold text-indigo-600">{name?.[0]?.toUpperCase() || 'B'}</span>
)}
</div>
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-900 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>
)}
{footer && (
<p className="text-xs text-gray-400 font-medium mt-3">{footer}</p>
)}
</div>
</div>
</div>
<div className="px-5 py-3 bg-gray-50 border-t border-gray-100 flex items-center justify-between gap-3">
<span className="text-xs text-gray-500 font-medium">
{disabled && loadingLabel ? loadingLabel : 'Ready to continue'}
</span>
<button
onClick={onAction}
disabled={disabled}
className={`px-3.5 py-2 rounded-lg border text-sm font-semibold transition disabled:opacity-50 disabled:cursor-not-allowed ${actionClassName}`}
>
{disabled && loadingLabel ? 'Working…' : actionLabel}
</button>
</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 sales channel has been connected successfully.</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>
);
}
export default function Businesses() { export default function Businesses() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setActiveBusiness } = useBusiness(); const { setActiveBusiness } = useBusiness();
const [businesses, setBusinesses] = useState([]); const [businesses, setBusinesses] = useState([]);
const [salesChannels, setSalesChannels] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading');
const [salesChannelsError, setSalesChannelsError] = useState('');
const [selectingBusinessId, setSelectingBusinessId] = useState(''); const [selectingBusinessId, setSelectingBusinessId] = useState('');
const [creatingSalesChannelId, setCreatingSalesChannelId] = useState('');
const [createdBusiness, setCreatedBusiness] = useState(null);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
async function load() { const configuredApplicationIds = useMemo(() => (
setLoading(true); new Set(
try { businesses
.map((business) => String(business?.applicationId || '').trim())
.filter(Boolean)
)
), [businesses]);
const availableSalesChannels = useMemo(() => (
salesChannels.filter((channel) => !configuredApplicationIds.has(getChannelId(channel)))
), [configuredApplicationIds, salesChannels]);
const loadBusinesses = useCallback(async () => {
const res = await apiClient.get('/api/businesses'); const res = await apiClient.get('/api/businesses');
setBusinesses(res.data.businesses || []); setBusinesses(res.data.businesses || []);
} catch { }, []);
const loadSalesChannels = useCallback(async () => {
setSalesChannelsStatus('loading');
const res = await apiClient.get('/api/businesses/sales-channels');
const channels = normalizeChannelsPayload(res.data).filter(isChannelActive);
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'); setError('Failed to load businesses');
}
if (salesChannelsRes.status === 'rejected') {
setSalesChannels([]);
setSalesChannelsStatus('error');
setSalesChannelsError(
salesChannelsRes.reason?.response?.data?.error || 'Active sales channels could not be loaded right now.'
);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [loadBusinesses, loadSalesChannels]);
useEffect(() => { load(); }, []); useEffect(() => { load(); }, [load]);
async function handleSelect(biz) { async function handleSelect(biz) {
setSelectingBusinessId(biz.businessId); setSelectingBusinessId(biz.businessId);
@ -74,6 +218,26 @@ export default function Businesses() {
} }
} }
async function handleCreateFromSalesChannel(channel) {
const salesChannelId = getChannelId(channel);
if (!salesChannelId) return;
setCreatingSalesChannelId(salesChannelId);
setError('');
setSalesChannelsError('');
try {
const res = await apiClient.post('/api/businesses', { salesChannelId });
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() { async function handleDelete() {
if (!deleteTarget) return; if (!deleteTarget) return;
setDeleting(true); setDeleting(true);
@ -83,6 +247,7 @@ export default function Businesses() {
await load(); await load();
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to delete business'); setError(err.response?.data?.error || 'Failed to delete business');
} finally {
setDeleting(false); setDeleting(false);
} }
} }
@ -95,40 +260,17 @@ export default function Businesses() {
); );
} }
// NO BUSINESSES YET
if (businesses.length === 0 && !showModal) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center max-w-lg px-8">
<div className="w-16 h-16 rounded-2xl bg-indigo-600 flex items-center justify-center mx-auto mb-6 text-2xl font-bold text-white shadow-lg shadow-indigo-600/20">
S
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-3 tracking-tight">SMS Template Extension</h1>
<p className="text-gray-500 text-base mb-8 leading-relaxed">
Generate TRAI-compliant SMS templates for your Fynd store. Add your first business to get started.
</p>
<button
onClick={() => setShowModal(true)}
className="px-8 py-3 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium shadow-sm transition-all focus:ring-2 focus:ring-offset-2 focus:ring-indigo-600"
>
Add Your First Business
</button>
</div>
{showModal && <RegisterBusinessModal onClose={() => { setShowModal(false); load(); }} />}
</div>
);
}
// BUSINESS LIST
return ( return (
<div className="min-h-screen bg-gray-50 p-8"> <div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Your Businesses</h1> <h1 className="text-2xl font-bold text-gray-900 tracking-tight">
<p className="text-sm text-gray-500 mt-1">Select a business to manage its SMS templates.</p> {businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business'}
</h1>
<p className="text-sm text-gray-500 mt-1">
Add directly from an active sales channel, or use the existing fallback modal for manual testing.
</p>
</div> </div>
<button <button
onClick={() => setShowModal(true)} onClick={() => setShowModal(true)}
@ -145,8 +287,67 @@ export default function Businesses() {
</div> </div>
)} )}
<section className="mb-8">
<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">Choose a live channel to create its business record directly.</p>
</div>
<button
onClick={() => setShowModal(true)}
className="text-sm font-semibold text-indigo-600 hover:text-indigo-700 transition"
>
Use fallback modal instead
</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="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{businesses.map(biz => ( {availableSalesChannels.map((channel) => {
const channelId = getChannelId(channel);
return (
<BusinessSummaryCard
key={channelId}
entity={channel}
actionLabel="Add Business"
actionTone="green"
onAction={() => handleCreateFromSalesChannel(channel)}
disabled={creatingSalesChannelId === channelId}
loadingLabel="Creating business from this sales channel…"
footer={getBusinessDomain(channel) ? `Sales channel • ${getBusinessDomain(channel)}` : 'Sales channel ready'}
/>
);
})}
</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 to add right now.</p>
<p className="text-sm text-gray-500">Already-configured channels are hidden here to avoid duplicate onboarding. You can still use the fallback Add Business modal for manual testing.</p>
</div>
)}
</section>
<section>
<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 <div
key={biz.businessId} 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" 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"
@ -156,16 +357,25 @@ export default function Businesses() {
onClick={() => handleSelect(biz)} onClick={() => handleSelect(biz)}
disabled={selectingBusinessId === biz.businessId} disabled={selectingBusinessId === biz.businessId}
> >
<div className="flex items-center gap-4 mb-3"> <div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-lg bg-indigo-600 flex items-center justify-center text-base font-bold text-white shrink-0 shadow-sm"> <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">
{biz.brandName?.[0]?.toUpperCase() || 'B'} {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>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<p className="font-bold text-gray-900 truncate">{biz.brandName}</p> <p className="font-bold text-gray-900 truncate">{getBusinessName(biz)}</p>
<p className="text-xs text-gray-500 font-medium truncate">{biz.domain}</p> {getBusinessDomain(biz) && (
</div> <p className="text-xs text-gray-500 font-medium truncate mt-1">{getBusinessDomain(biz)}</p>
</div> )}
<p className="text-xs text-gray-400 font-medium"> {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' })} Added {new Date(biz.createdAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
</p> </p>
{selectingBusinessId === biz.businessId && ( {selectingBusinessId === biz.businessId && (
@ -174,6 +384,8 @@ export default function Businesses() {
Opening Opening
</div> </div>
)} )}
</div>
</div>
</button> </button>
<div className="px-6 py-3 bg-gray-50 border-t border-gray-100 flex justify-between items-center"> <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> <span className="text-xs text-indigo-600 font-semibold group-hover:underline">Click to manage </span>
@ -187,12 +399,27 @@ export default function Businesses() {
</div> </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">Start from an active sales channel above, or use the Add Business modal as a fallback.</p>
</div>
)}
</section>
</div> </div>
{showModal && <RegisterBusinessModal onClose={() => { setShowModal(false); load(); }} />} {showModal && (
<RegisterBusinessModal
onClose={() => { setShowModal(false); load(); }}
initialSalesChannels={availableSalesChannels}
initialSalesChannelsStatus={salesChannelsStatus}
initialSalesChannelsError={salesChannelsError}
/>
)}
{createdBusiness && <BusinessCreatedModal business={createdBusiness} onClose={() => setCreatedBusiness(null)} />}
{deleteTarget && ( {deleteTarget && (
<DeleteConfirmModal <DeleteConfirmModal
businessName={deleteTarget.brandName} businessName={getBusinessName(deleteTarget)}
onCancel={() => setDeleteTarget(null)} onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete} onConfirm={handleDelete}
deleting={deleting} deleting={deleting}

View File

@ -0,0 +1,64 @@
function normalizeList(value) {
return Array.isArray(value) ? value.filter(Boolean) : [];
}
export function normalizeChannelsPayload(data) {
if (Array.isArray(data)) return data;
if (Array.isArray(data?.salesChannels)) return data.salesChannels;
if (Array.isArray(data?.channels)) return data.channels;
return [];
}
export function getChannelId(channel) {
return channel?.salesChannelId || channel?.id || channel?._id || '';
}
export function isChannelActive(channel) {
const status = String(channel?.status || channel?.state || '').toLowerCase();
if (typeof channel?.isActive === 'boolean') return channel.isActive;
if (typeof channel?.active === 'boolean') return channel.active;
if (typeof channel?.enabled === 'boolean') return channel.enabled;
if (status) return ['active', 'enabled', 'live', 'connected'].includes(status);
return true;
}
export function getBusinessName(entity) {
return entity?.brandName || entity?.name || entity?.title || 'Business';
}
export function getBusinessDomain(entity) {
const directDomain = String(entity?.domain || '').trim();
if (directDomain) return directDomain;
const websiteUrl = String(entity?.websiteUrl || entity?.url || '').trim();
if (!websiteUrl) return '';
return websiteUrl
.replace(/^https?:\/\//i, '')
.replace(/^www\./i, '')
.replace(/\/.*$/, '');
}
export function getBusinessTagline(entity) {
const taglines = normalizeList(entity?.taglines);
const firstTagline = taglines.find((tagline) => String(tagline || '').trim());
if (firstTagline) return String(firstTagline).trim();
const fallback = entity?.tagline || entity?.description || entity?.metaDescription || '';
return String(fallback).trim();
}
export function getBusinessImage(entity) {
const relevantImage = normalizeList(entity?.relevantImagePaths)[0];
if (relevantImage) return relevantImage;
return (
entity?.imageUrl
|| entity?.logoUrl
|| entity?.brandImageUrl
|| entity?.image
|| ''
);
}

View File

@ -279,6 +279,30 @@ function normalizeSalesChannel(application = {}, domains = []) {
}; };
} }
function getBusinessPreviewSummary(source = {}) {
const taglines = Array.isArray(source?.taglines)
? source.taglines.map((tagline) => normalizeText(tagline)).filter(Boolean)
: [];
const relevantImagePaths = Array.isArray(source?.relevantImagePaths)
? source.relevantImagePaths.map((path) => normalizeText(path)).filter(Boolean)
: [];
return {
previewTagline: taglines[0] || '',
previewImagePath: relevantImagePaths[0] || '',
};
}
function mergeBusinessSummary(baseBusiness = {}, context = null) {
const previewSummary = getBusinessPreviewSummary(context || baseBusiness);
return {
...baseBusiness,
previewTagline: normalizeText(baseBusiness.previewTagline) || previewSummary.previewTagline,
previewImagePath: normalizeText(baseBusiness.previewImagePath) || previewSummary.previewImagePath,
};
}
async function getPlatformClient(req, companyId) { async function getPlatformClient(req, companyId) {
if (req?.platformClient) return req.platformClient; if (req?.platformClient) return req.platformClient;
return getPlatformClientForCompany(companyId); return getPlatformClientForCompany(companyId);
@ -881,8 +905,20 @@ function getMissingMandatoryProviderFields(provider = {}) {
// GET /api/businesses // GET /api/businesses
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const businesses = await getIndex(getCompanyId(req)); const merchantId = getCompanyId(req);
res.json({ businesses }); const businesses = await getIndex(merchantId);
const hydratedBusinesses = await Promise.all(
businesses.map(async (business) => {
if (normalizeText(business.previewTagline) || normalizeText(business.previewImagePath)) {
return mergeBusinessSummary(business);
}
const context = await fetchJSON(businessRoot(merchantId, business.businessId), 'context').catch(() => null);
return mergeBusinessSummary(business, context);
})
);
res.json({ businesses: hydratedBusinesses });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
@ -900,7 +936,7 @@ router.get('/sales-channels', async (req, res) => {
const applications = await listAllSalesChannels(platformClient); const applications = await listAllSalesChannels(platformClient);
const salesChannels = applications const salesChannels = applications
.map((application) => normalizeSalesChannel(application)) .map((application) => normalizeSalesChannel(application))
.filter((channel) => channel.id && channel.name) .filter((channel) => channel.id && channel.name && channel.isActive)
.sort((left, right) => left.name.localeCompare(right.name)); .sort((left, right) => left.name.localeCompare(right.name));
res.json({ salesChannels }); res.json({ salesChannels });
@ -1020,12 +1056,15 @@ router.post('/', async (req, res) => {
await uploadJSON(bizRoot, 'events', { events: DEFAULT_EVENTS }); await uploadJSON(bizRoot, 'events', { events: DEFAULT_EVENTS });
// 6. Update index.json // 6. Update index.json
const previewSummary = getBusinessPreviewSummary(contextJson);
businesses.push({ businesses.push({
businessId, businessId,
companyId: merchantId, companyId: merchantId,
applicationId, applicationId,
brandName: contextJson.brandName, brandName: contextJson.brandName,
domain: contextJson.domain, domain: contextJson.domain,
previewTagline: previewSummary.previewTagline,
previewImagePath: previewSummary.previewImagePath,
createdAt: contextJson.createdAt, createdAt: contextJson.createdAt,
updatedAt: contextJson.updatedAt, updatedAt: contextJson.updatedAt,
}); });