Proper API to fetch sales channel directly
This commit is contained in:
parent
52fcf8a7af
commit
b8451e7df8
|
|
@ -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 (
|
||||
<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">
|
||||
|
|
@ -113,8 +128,29 @@ export default function RegisterBusinessModal({ onClose }) {
|
|||
<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>
|
||||
<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-indigo-600 font-bold text-lg mb-6 tracking-tight">{brandName}</p>
|
||||
<p className="text-gray-500 text-sm mb-4 font-medium">Brand detected and ready for onboarding.</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
|
||||
onClick={onClose}
|
||||
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 shadow-sm text-white font-medium transition"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
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 {
|
||||
getBusinessDomain,
|
||||
getBusinessImage,
|
||||
getBusinessName,
|
||||
getBusinessTagline,
|
||||
getChannelId,
|
||||
isChannelActive,
|
||||
normalizeChannelsPayload,
|
||||
} from '../utils/businessProfile';
|
||||
|
||||
function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
|
||||
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() {
|
||||
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 (
|
||||
<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 (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Your Businesses</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Select a business to manage its SMS templates.</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">
|
||||
{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>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
|
|
@ -145,54 +287,139 @@ export default function Businesses() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{businesses.map(biz => (
|
||||
<div
|
||||
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"
|
||||
<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"
|
||||
>
|
||||
<button
|
||||
className="w-full text-left p-6"
|
||||
onClick={() => handleSelect(biz)}
|
||||
disabled={selectingBusinessId === biz.businessId}
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<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">
|
||||
{biz.brandName?.[0]?.toUpperCase() || 'B'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-gray-900 truncate">{biz.brandName}</p>
|
||||
<p className="text-xs text-gray-500 font-medium truncate">{biz.domain}</p>
|
||||
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">
|
||||
{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
|
||||
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"
|
||||
>
|
||||
<button
|
||||
className="w-full text-left p-6"
|
||||
onClick={() => handleSelect(biz)}
|
||||
disabled={selectingBusinessId === biz.businessId}
|
||||
>
|
||||
<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">
|
||||
{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 className="min-w-0 flex-1">
|
||||
<p className="font-bold text-gray-900 truncate">{getBusinessName(biz)}</p>
|
||||
{getBusinessDomain(biz) && (
|
||||
<p className="text-xs text-gray-500 font-medium truncate mt-1">{getBusinessDomain(biz)}</p>
|
||||
)}
|
||||
{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' })}
|
||||
</p>
|
||||
{selectingBusinessId === biz.businessId && (
|
||||
<div className="mt-3 inline-flex items-center gap-2 text-xs text-indigo-600 font-semibold">
|
||||
<span className="w-3.5 h-3.5 border-2 border-indigo-200 border-t-indigo-600 rounded-full animate-spin" />
|
||||
Opening…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<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>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteTarget(biz); }}
|
||||
className="text-xs text-red-500 hover:text-red-700 font-medium transition"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 font-medium">
|
||||
Added {new Date(biz.createdAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</p>
|
||||
{selectingBusinessId === biz.businessId && (
|
||||
<div className="mt-3 inline-flex items-center gap-2 text-xs text-indigo-600 font-semibold">
|
||||
<span className="w-3.5 h-3.5 border-2 border-indigo-200 border-t-indigo-600 rounded-full animate-spin" />
|
||||
Opening…
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<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>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteTarget(biz); }}
|
||||
className="text-xs text-red-500 hover:text-red-700 font-medium transition"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{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 && (
|
||||
<DeleteConfirmModal
|
||||
businessName={deleteTarget.brandName}
|
||||
businessName={getBusinessName(deleteTarget)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
deleting={deleting}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|| ''
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user