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 { 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"
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user