diff --git a/client/src/components/RegisterBusinessModal.jsx b/client/src/components/RegisterBusinessModal.jsx index a11e5e3..197b758 100644 --- a/client/src/components/RegisterBusinessModal.jsx +++ b/client/src/components/RegisterBusinessModal.jsx @@ -1,32 +1,42 @@ import { useEffect, useMemo, useState } from 'react'; import apiClient from '../api/client'; +import { + getBusinessDomain, + getBusinessImage, + getBusinessName, + getBusinessTagline, + getChannelId, + normalizeChannelsPayload, +} from '../utils/businessProfile'; -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 []; -} - -function getChannelId(channel) { - return channel?.salesChannelId || channel?.id || channel?._id || ''; -} - -export default function RegisterBusinessModal({ onClose }) { +export default function RegisterBusinessModal({ + onClose, + initialSalesChannels = [], + initialSalesChannelsStatus = 'idle', + initialSalesChannelsError = '', +}) { const [url, setUrl] = useState(''); const [status, setStatus] = useState('idle'); // idle | loading | success | error - const [brandName, setBrandName] = useState(''); + const [createdBusiness, setCreatedBusiness] = useState(null); const [error, setError] = useState(''); - const [entryMode, setEntryMode] = useState('sales-channel'); - const [salesChannels, setSalesChannels] = useState([]); - const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading'); // loading | success | error - const [salesChannelsError, setSalesChannelsError] = useState(''); - const [selectedSalesChannelId, setSelectedSalesChannelId] = useState(''); + const [entryMode, setEntryMode] = useState( + initialSalesChannelsStatus === 'error' ? 'manual' : 'sales-channel' + ); + const [salesChannels, setSalesChannels] = useState(initialSalesChannels); + 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(''); useEffect(() => { let cancelled = false; + if (initialSalesChannelsStatus !== 'idle') return undefined; + async function fetchSalesChannels() { setSalesChannelsStatus('loading'); setSalesChannelsError(''); @@ -59,7 +69,7 @@ export default function RegisterBusinessModal({ onClose }) { return () => { cancelled = true; }; - }, []); + }, [initialSalesChannelsStatus]); const filteredSalesChannels = useMemo(() => { if (!searchQuery.trim()) return salesChannels; @@ -97,7 +107,7 @@ export default function RegisterBusinessModal({ onClose }) { : { websiteUrl: url.trim() }; const res = await apiClient.post('/api/businesses', payload); - setBrandName(res.data.brandName); + setCreatedBusiness(res.data); setStatus('success'); } catch (err) { 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 (
@@ -113,8 +128,29 @@ export default function RegisterBusinessModal({ onClose }) {

Business Added!

-

Brand detected:

-

{brandName}

+

Brand detected and ready for onboarding.

+
+
+
+ {successImage ? ( + {successName} + ) : ( + + {successName?.[0]?.toUpperCase() || 'B'} + + )} +
+
+

{successName}

+ {successDomain && ( +

{successDomain}

+ )} + {successTagline && ( +

{successTagline}

+ )} +
+
+
+
+
+ ); +} + +function BusinessCreatedModal({ business, onClose }) { + const name = getBusinessName(business); + const domain = getBusinessDomain(business); + const tagline = getBusinessTagline(business); + const image = getBusinessImage(business); + + return ( +
+
+
+

Business Added!

+

Your sales channel has been connected successfully.

+
+
+
+ {image ? ( + {name} + ) : ( + {name?.[0]?.toUpperCase() || 'B'} + )} +
+
+

{name}

+ {domain &&

{domain}

} + {tagline &&

{tagline}

} +
+
+
+ +
+
+ ); +} + export default function Businesses() { const navigate = useNavigate(); const { setActiveBusiness } = useBusiness(); const [businesses, setBusinesses] = useState([]); + const [salesChannels, setSalesChannels] = useState([]); const [loading, setLoading] = useState(true); + const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading'); + const [salesChannelsError, setSalesChannelsError] = useState(''); const [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(''); - async function load() { + const configuredApplicationIds = useMemo(() => ( + new Set( + businesses + .map((business) => String(business?.applicationId || '').trim()) + .filter(Boolean) + ) + ), [businesses]); + + const availableSalesChannels = useMemo(() => ( + salesChannels.filter((channel) => !configuredApplicationIds.has(getChannelId(channel))) + ), [configuredApplicationIds, salesChannels]); + + const loadBusinesses = useCallback(async () => { + const res = await apiClient.get('/api/businesses'); + setBusinesses(res.data.businesses || []); + }, []); + + 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 res = await apiClient.get('/api/businesses'); - setBusinesses(res.data.businesses || []); - } catch { - setError('Failed to load businesses'); + const [businessesRes, salesChannelsRes] = await Promise.allSettled([ + loadBusinesses(), + loadSalesChannels(), + ]); + + if (businessesRes.status === 'rejected') { + setError('Failed to load businesses'); + } + + if (salesChannelsRes.status === 'rejected') { + setSalesChannels([]); + setSalesChannelsStatus('error'); + setSalesChannelsError( + salesChannelsRes.reason?.response?.data?.error || 'Active sales channels could not be loaded right now.' + ); + } } finally { setLoading(false); } - } + }, [loadBusinesses, loadSalesChannels]); - useEffect(() => { load(); }, []); + useEffect(() => { load(); }, [load]); async function handleSelect(biz) { 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() { if (!deleteTarget) return; setDeleting(true); @@ -83,6 +247,7 @@ export default function Businesses() { await load(); } catch (err) { setError(err.response?.data?.error || 'Failed to delete business'); + } finally { setDeleting(false); } } @@ -95,40 +260,17 @@ export default function Businesses() { ); } - // ── NO BUSINESSES YET ────────────────────────────────────────────────────── - if (businesses.length === 0 && !showModal) { - return ( -
-
-
- S -
-

SMS Template Extension

-

- Generate TRAI-compliant SMS templates for your Fynd store. Add your first business to get started. -

- -
- {showModal && { setShowModal(false); load(); }} />} -
- ); - } - - // ── BUSINESS LIST ────────────────────────────────────────────────────────── return (
- - {/* Header */}
-

Your Businesses

-

Select a business to manage its SMS templates.

+

+ {businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business'} +

+

+ Add directly from an active sales channel, or use the existing fallback modal for manual testing. +

)} -
- {businesses.map(biz => ( -
+
+
+

Active Sales Channels

+

Choose a live channel to create its business record directly.

+
+ +
+ + {salesChannelsError && ( +
+ {salesChannelsError} +
+ )} + + {salesChannelsStatus === 'loading' ? ( +
+
+

Loading active sales channels…

+
+ ) : availableSalesChannels.length > 0 ? ( +
+ {availableSalesChannels.map((channel) => { + const channelId = getChannelId(channel); + + return ( + handleCreateFromSalesChannel(channel)} + disabled={creatingSalesChannelId === channelId} + loadingLabel="Creating business from this sales channel…" + footer={getBusinessDomain(channel) ? `Sales channel • ${getBusinessDomain(channel)}` : 'Sales channel ready'} + /> + ); + })} +
+ ) : ( +
+

No active sales channels are available to add right now.

+

Already-configured channels are hidden here to avoid duplicate onboarding. You can still use the fallback Add Business modal for manual testing.

+
+ )} + + +
+
+

Configured Businesses

+

Select a business to manage its SMS templates.

+
+ + {businesses.length > 0 ? ( +
+ {businesses.map((biz) => ( +
+ +
+ Click to manage → +
-

- Added {new Date(biz.createdAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })} -

- {selectingBusinessId === biz.businessId && ( -
- - Opening… -
- )} - -
- Click to manage → - -
+ ))}
- ))} -
+ ) : ( +
+

No configured businesses yet.

+

Start from an active sales channel above, or use the Add Business modal as a fallback.

+
+ )} +
- {showModal && { setShowModal(false); load(); }} />} + {showModal && ( + { setShowModal(false); load(); }} + initialSalesChannels={availableSalesChannels} + initialSalesChannelsStatus={salesChannelsStatus} + initialSalesChannelsError={salesChannelsError} + /> + )} + {createdBusiness && setCreatedBusiness(null)} />} {deleteTarget && ( setDeleteTarget(null)} onConfirm={handleDelete} deleting={deleting} diff --git a/client/src/utils/businessProfile.js b/client/src/utils/businessProfile.js new file mode 100644 index 0000000..6e06e94 --- /dev/null +++ b/client/src/utils/businessProfile.js @@ -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 + || '' + ); +} diff --git a/server/routes/businesses.js b/server/routes/businesses.js index fd052ff..a4e3471 100644 --- a/server/routes/businesses.js +++ b/server/routes/businesses.js @@ -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) { if (req?.platformClient) return req.platformClient; return getPlatformClientForCompany(companyId); @@ -881,8 +905,20 @@ function getMissingMandatoryProviderFields(provider = {}) { // GET /api/businesses router.get('/', async (req, res) => { try { - const businesses = await getIndex(getCompanyId(req)); - res.json({ businesses }); + const merchantId = getCompanyId(req); + 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) { res.status(500).json({ error: err.message }); } @@ -900,7 +936,7 @@ router.get('/sales-channels', async (req, res) => { const applications = await listAllSalesChannels(platformClient); const salesChannels = applications .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)); res.json({ salesChannels }); @@ -1020,12 +1056,15 @@ router.post('/', async (req, res) => { await uploadJSON(bizRoot, 'events', { events: DEFAULT_EVENTS }); // 6. Update index.json + const previewSummary = getBusinessPreviewSummary(contextJson); businesses.push({ businessId, companyId: merchantId, applicationId, brandName: contextJson.brandName, domain: contextJson.domain, + previewTagline: previewSummary.previewTagline, + previewImagePath: previewSummary.previewImagePath, createdAt: contextJson.createdAt, updatedAt: contextJson.updatedAt, });