bolt-templates-sms-extensio.../client/src/pages/Businesses.jsx

424 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { fetchActiveSalesChannels } from '../utils/fyndSalesChannels';
import {
getBusinessDomain,
getBusinessImage,
getBusinessName,
getBusinessTagline,
getChannelId,
} from '../utils/businessProfile';
function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
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-lg p-8 w-full max-w-md shadow-sm">
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mx-auto mb-4">
<span className="text-xl">🗑</span>
</div>
<h3 className="text-lg font-semibold text-gray-900 text-center mb-2">Delete Business?</h3>
<p className="text-sm text-gray-500 text-center mb-6">
This will permanently delete <span className="text-gray-900 font-medium">{businessName}</span> and all its events, templates, and images. This cannot be undone.
</p>
<div className="flex gap-3">
<button
onClick={onCancel}
disabled={deleting}
className="flex-1 py-2.5 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition disabled:opacity-50"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={deleting}
className="flex-1 py-2.5 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition shadow-sm disabled:opacity-50 flex items-center justify-center gap-2"
>
{deleting ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Deleting</> : 'Yes, Delete'}
</button>
</div>
</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-lg p-8 w-full max-w-md shadow-sm">
<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 business is ready for onboarding.</p>
<div className="rounded-lg 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-lg 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-primary-blue">{name?.[0]?.toUpperCase() || 'B'}</span>
)}
</div>
<div className="min-w-0">
<p className="text-primary-blue 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-primary-blue hover:bg-primary-dark shadow-sm text-white font-medium transition"
>
Done
</button>
</div>
</div>
);
}
function SalesChannelCard({ channel, disabled, onImport }) {
const name = getBusinessName(channel);
const domain = getBusinessDomain(channel);
const image = getBusinessImage(channel);
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition hover:border-primary-blue hover:shadow-md">
<div className="flex items-start gap-3">
<div className="w-12 h-12 rounded-lg overflow-hidden bg-gray-50 border border-gray-200 shrink-0 flex items-center justify-center">
{image ? (
<img src={image} alt={name} className="w-full h-full object-cover" />
) : (
<span className="text-sm font-bold text-primary-blue">{name?.[0]?.toUpperCase() || 'S'}</span>
)}
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-gray-900 truncate">{name}</p>
<p className="text-sm text-gray-500 truncate mt-1">{domain || 'Domain unavailable'}</p>
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<span className="text-xs text-gray-500 font-medium">
{channel.websiteUrl ? 'Ready to scrape' : 'Use manual URL fallback'}
</span>
<button
onClick={onImport}
disabled={disabled || !channel.websiteUrl}
className="px-3.5 py-2 rounded-lg border border-green-200 bg-green-50 text-green-700 text-sm font-semibold transition hover:bg-green-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
{disabled ? 'Importing…' : 'Import'}
</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 [salesChannelQuery, setSalesChannelQuery] = 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('');
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 showSalesChannelsSection = salesChannelsStatus === 'success' && availableSalesChannels.length > 0;
const filteredSalesChannels = useMemo(() => {
const query = salesChannelQuery.trim().toLowerCase();
if (!query) return availableSalesChannels;
return availableSalesChannels.filter((channel) => {
const name = String(getBusinessName(channel) || '').toLowerCase();
const domain = String(getBusinessDomain(channel) || '').toLowerCase();
return name.includes(query) || domain.includes(query);
});
}, [availableSalesChannels, salesChannelQuery]);
const loadBusinesses = useCallback(async () => {
const res = await apiClient.get('/api/businesses');
setBusinesses(res.data.businesses || []);
}, []);
const loadSalesChannels = useCallback(async () => {
setSalesChannelsStatus('loading');
const channels = await fetchActiveSalesChannels();
setSalesChannels(channels);
setSalesChannelsStatus('success');
}, []);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const [businessesRes, salesChannelsRes] = await Promise.allSettled([
loadBusinesses(),
loadSalesChannels(),
]);
if (businessesRes.status === 'rejected') {
setError('Failed to load businesses');
}
if (salesChannelsRes.status === 'rejected') {
setSalesChannels([]);
setSalesChannelsStatus('error');
}
} finally {
setLoading(false);
}
}, [loadBusinesses, loadSalesChannels]);
useEffect(() => { load(); }, [load]);
async function handleSelect(biz) {
setSelectingBusinessId(biz.businessId);
setError('');
try {
const setupComplete = await setActiveBusiness(biz);
navigate(`/${biz.businessId}/${setupComplete ? 'events' : 'global-sms'}`);
} catch (err) {
setError(err.response?.data?.error || 'Failed to open business');
} finally {
setSelectingBusinessId('');
}
}
async function handleCreateFromSalesChannel(channel) {
const applicationId = getChannelId(channel);
if (!applicationId) return;
if (!channel.websiteUrl) {
setError('A website URL could not be derived from this sales channel. Please use Add Business and enter the URL manually.');
return;
}
setCreatingSalesChannelId(applicationId);
setError('');
try {
const res = await apiClient.post('/api/businesses', {
applicationId,
websiteUrl: channel.websiteUrl,
});
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);
try {
await apiClient.delete(`/api/businesses/${deleteTarget.businessId}`);
setDeleteTarget(null);
await load();
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete business');
} finally {
setDeleting(false);
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-8 h-8 border-2 border-refresh-active border-t-indigo-600 rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<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">
{showSalesChannelsSection
? 'Import from an active sales channel when available, or use the website URL fallback to scrape manually.'
: 'Add a storefront URL and well scrape it to set up your business.'}
</p>
</div>
<button
onClick={() => setShowModal(true)}
className="px-4 py-2.5 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold shadow-sm transition"
>
+ Add Business
</button>
</div>
{error && (
<div className="mb-6 px-4 py-3 rounded-md bg-red-50 border border-red-200 text-red-700 font-medium text-sm flex items-center justify-between">
{error}
<button onClick={() => setError('')} className="text-red-500 hover:text-red-700 font-bold">&times;</button>
</div>
)}
<section className="mb-10">
<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-lg bg-white border border-gray-200 shadow-sm hover:border-primary-blue hover:ring-1 hover:ring-primary-blue 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-lg 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-primary-blue">
{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-primary-blue font-semibold">
<span className="w-3.5 h-3.5 border-2 border-refresh-active 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-primary-blue 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-lg 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">Use Add Business to enter a storefront URL and get started.</p>
</div>
)}
</section>
{showSalesChannelsSection && (
<section>
<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">These are pulled directly from Commerce and can be scraped into businesses with one click.</p>
</div>
<button
onClick={() => setShowModal(true)}
className="text-sm font-semibold text-primary-blue hover:text-primary-dark transition"
>
Use website URL fallback
</button>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm">
<div className="mb-4">
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Search Sales Channels</label>
<input
type="text"
value={salesChannelQuery}
onChange={(e) => setSalesChannelQuery(e.target.value)}
placeholder="Search by channel name or domain"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{filteredSalesChannels.map((channel) => {
const channelId = getChannelId(channel);
return (
<SalesChannelCard
key={channelId}
channel={channel}
disabled={creatingSalesChannelId === channelId}
onImport={() => handleCreateFromSalesChannel(channel)}
/>
);
})}
</div>
{filteredSalesChannels.length === 0 && (
<div className="mt-4 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-6 text-center">
<p className="text-sm font-semibold text-gray-900">No active sales channels matched your search.</p>
<p className="text-sm text-gray-500 mt-1">Use the website URL fallback if you want to scrape a storefront directly.</p>
</div>
)}
</div>
</section>
)}
</div>
{showModal && (
<RegisterBusinessModal onClose={() => { setShowModal(false); loadBusinesses(); }} />
)}
{createdBusiness && <BusinessCreatedModal business={createdBusiness} onClose={() => setCreatedBusiness(null)} />}
{deleteTarget && (
<DeleteConfirmModal
businessName={getBusinessName(deleteTarget)}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
deleting={deleting}
/>
)}
</div>
);
}