Not using offline token based funcitonality at all. Frontend calls to fetch sales channel and other things on runtime with user session token
This commit is contained in:
parent
b8451e7df8
commit
b5ebb830d8
|
|
@ -16,7 +16,6 @@ RUN npm ci --omit=dev
|
||||||
|
|
||||||
COPY server/index.js ./
|
COPY server/index.js ./
|
||||||
COPY server/fdk.js ./
|
COPY server/fdk.js ./
|
||||||
COPY server/postgresFdkStorage.js ./
|
|
||||||
COPY server/config ./config
|
COPY server/config ./config
|
||||||
COPY server/routes ./routes
|
COPY server/routes ./routes
|
||||||
COPY server/services ./services
|
COPY server/services ./services
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,29 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
import {
|
import {
|
||||||
getBusinessDomain,
|
getBusinessDomain,
|
||||||
getBusinessImage,
|
getBusinessImage,
|
||||||
getBusinessName,
|
getBusinessName,
|
||||||
getBusinessTagline,
|
getBusinessTagline,
|
||||||
getChannelId,
|
|
||||||
normalizeChannelsPayload,
|
|
||||||
} from '../utils/businessProfile';
|
} from '../utils/businessProfile';
|
||||||
|
|
||||||
export default function RegisterBusinessModal({
|
export default function RegisterBusinessModal({ onClose }) {
|
||||||
onClose,
|
|
||||||
initialSalesChannels = [],
|
|
||||||
initialSalesChannelsStatus = 'idle',
|
|
||||||
initialSalesChannelsError = '',
|
|
||||||
}) {
|
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [status, setStatus] = useState('idle'); // idle | loading | success | error
|
const [status, setStatus] = useState('idle');
|
||||||
const [createdBusiness, setCreatedBusiness] = useState(null);
|
const [createdBusiness, setCreatedBusiness] = useState(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = 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('');
|
|
||||||
try {
|
|
||||||
const res = await apiClient.get('/api/businesses/sales-channels');
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const channels = normalizeChannelsPayload(res.data);
|
|
||||||
setSalesChannels(channels);
|
|
||||||
|
|
||||||
if (channels.length > 0) {
|
|
||||||
setSelectedSalesChannelId(getChannelId(channels[0]));
|
|
||||||
setSalesChannelsStatus('success');
|
|
||||||
setEntryMode('sales-channel');
|
|
||||||
} else {
|
|
||||||
setSalesChannelsStatus('error');
|
|
||||||
setSalesChannelsError('No sales channels were found for this company yet. You can still enter the website URL manually.');
|
|
||||||
setEntryMode('manual');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (cancelled) return;
|
|
||||||
setSalesChannels([]);
|
|
||||||
setSalesChannelsStatus('error');
|
|
||||||
setSalesChannelsError(err.response?.data?.error || 'Sales channels could not be fetched right now. You can still enter the website URL manually.');
|
|
||||||
setEntryMode('manual');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchSalesChannels();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [initialSalesChannelsStatus]);
|
|
||||||
|
|
||||||
const filteredSalesChannels = useMemo(() => {
|
|
||||||
if (!searchQuery.trim()) return salesChannels;
|
|
||||||
const query = searchQuery.trim().toLowerCase();
|
|
||||||
return salesChannels.filter((channel) => {
|
|
||||||
const name = String(channel?.name || '').toLowerCase();
|
|
||||||
const domain = String(channel?.domain || '').toLowerCase();
|
|
||||||
return name.includes(query) || domain.includes(query);
|
|
||||||
});
|
|
||||||
}, [salesChannels, searchQuery]);
|
|
||||||
|
|
||||||
const canUseSalesChannels = salesChannelsStatus === 'success' && salesChannels.length > 0;
|
|
||||||
const isSalesChannelMode = entryMode === 'sales-channel' && canUseSalesChannels;
|
|
||||||
const effectiveSelectedSalesChannelId = filteredSalesChannels.some(
|
|
||||||
(channel) => getChannelId(channel) === selectedSalesChannelId
|
|
||||||
)
|
|
||||||
? selectedSalesChannelId
|
|
||||||
: (filteredSalesChannels[0] ? getChannelId(filteredSalesChannels[0]) : '');
|
|
||||||
const submitDisabled = status === 'loading'
|
|
||||||
|| (isSalesChannelMode ? !effectiveSelectedSalesChannelId : !url.trim());
|
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!url.trim()) return;
|
||||||
const isSalesChannelMode = entryMode === 'sales-channel' && salesChannelsStatus === 'success';
|
|
||||||
if (isSalesChannelMode && !effectiveSelectedSalesChannelId) return;
|
|
||||||
if (!isSalesChannelMode && !url.trim()) return;
|
|
||||||
|
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = isSalesChannelMode
|
const res = await apiClient.post('/api/businesses', {
|
||||||
? { salesChannelId: effectiveSelectedSalesChannelId }
|
websiteUrl: url.trim(),
|
||||||
: { websiteUrl: url.trim() };
|
});
|
||||||
|
|
||||||
const res = await apiClient.post('/api/businesses', payload);
|
|
||||||
setCreatedBusiness(res.data);
|
setCreatedBusiness(res.data);
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -165,87 +82,11 @@ export default function RegisterBusinessModal({
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-2 tracking-tight">Add a Business</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-2 tracking-tight">Add a Business</h2>
|
||||||
<p className="text-gray-500 text-sm leading-relaxed">
|
<p className="text-gray-500 text-sm leading-relaxed">
|
||||||
Choose a sales channel first when available. If that lookup fails, you can still enter the website URL and we'll scrape it directly.
|
Enter the storefront website URL and we'll scrape it to detect the brand, images, and copy you need for onboarding.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="rounded-xl bg-gray-50 p-1 grid grid-cols-2 gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => canUseSalesChannels && setEntryMode('sales-channel')}
|
|
||||||
disabled={!canUseSalesChannels || status === 'loading'}
|
|
||||||
className={`px-3 py-2 rounded-[10px] text-sm font-semibold transition ${
|
|
||||||
isSalesChannelMode
|
|
||||||
? 'bg-white text-gray-900 shadow-sm'
|
|
||||||
: canUseSalesChannels
|
|
||||||
? 'text-gray-500 hover:text-gray-900'
|
|
||||||
: 'text-gray-300 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Sales Channel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEntryMode('manual')}
|
|
||||||
disabled={status === 'loading'}
|
|
||||||
className={`px-3 py-2 rounded-[10px] text-sm font-semibold transition ${
|
|
||||||
!isSalesChannelMode ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Website URL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isSalesChannelMode ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5 tracking-wide">Search Sales Channels</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Search by name or domain"
|
|
||||||
disabled={status === 'loading' || salesChannelsStatus === 'loading'}
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400 font-medium focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition disabled:opacity-50 text-sm shadow-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5 tracking-wide">Sales Channel</label>
|
|
||||||
{filteredSalesChannels.length > 0 ? (
|
|
||||||
<select
|
|
||||||
value={effectiveSelectedSalesChannelId}
|
|
||||||
onChange={(e) => setSelectedSalesChannelId(e.target.value)}
|
|
||||||
disabled={status === 'loading'}
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg bg-white border border-gray-300 text-gray-900 font-medium focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition disabled:opacity-50 text-sm shadow-sm"
|
|
||||||
>
|
|
||||||
{filteredSalesChannels.map((channel) => (
|
|
||||||
<option key={getChannelId(channel)} value={getChannelId(channel)}>
|
|
||||||
{channel.name}{channel.domain ? ` • ${channel.domain}` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<div className="w-full px-4 py-2.5 rounded-lg bg-gray-50 border border-gray-200 text-sm text-gray-500 font-medium">
|
|
||||||
No sales channels matched your search.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{salesChannelsStatus === 'loading' && (
|
|
||||||
<p className="text-xs text-gray-500 font-medium">
|
|
||||||
Loading sales channels for this company…
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{salesChannelsError && (
|
|
||||||
<p className="text-sm text-amber-700 font-medium bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
|
||||||
{salesChannelsError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5 tracking-wide">Website URL</label>
|
<label className="block text-sm font-semibold text-gray-700 mb-1.5 tracking-wide">Website URL</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -255,15 +96,9 @@ export default function RegisterBusinessModal({
|
||||||
placeholder="https://yourstore.com"
|
placeholder="https://yourstore.com"
|
||||||
disabled={status === 'loading'}
|
disabled={status === 'loading'}
|
||||||
className="w-full px-4 py-2.5 rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400 font-medium focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition disabled:opacity-50 text-sm shadow-sm"
|
className="w-full px-4 py-2.5 rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400 font-medium focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition disabled:opacity-50 text-sm shadow-sm"
|
||||||
required={!isSalesChannelMode}
|
required
|
||||||
/>
|
/>
|
||||||
{salesChannelsError && (
|
|
||||||
<p className="mt-2 text-xs text-gray-500 font-medium">
|
|
||||||
{salesChannelsError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<p className="text-sm text-red-600 font-medium bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</p>
|
<p className="text-sm text-red-600 font-medium bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</p>
|
||||||
|
|
@ -280,7 +115,7 @@ export default function RegisterBusinessModal({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitDisabled}
|
disabled={status === 'loading' || !url.trim()}
|
||||||
className="flex-[1.2] py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium transition shadow-sm disabled:opacity-50 flex items-center justify-center gap-2"
|
className="flex-[1.2] py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium transition shadow-sm disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,13 @@ 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 { fetchActiveSalesChannels } from '../utils/fyndSalesChannels';
|
||||||
import {
|
import {
|
||||||
getBusinessDomain,
|
getBusinessDomain,
|
||||||
getBusinessImage,
|
getBusinessImage,
|
||||||
getBusinessName,
|
getBusinessName,
|
||||||
getBusinessTagline,
|
getBusinessTagline,
|
||||||
getChannelId,
|
getChannelId,
|
||||||
isChannelActive,
|
|
||||||
normalizeChannelsPayload,
|
|
||||||
} from '../utils/businessProfile';
|
} from '../utils/businessProfile';
|
||||||
|
|
||||||
function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
|
function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
|
||||||
|
|
@ -45,57 +44,6 @@ 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 }) {
|
function BusinessCreatedModal({ business, onClose }) {
|
||||||
const name = getBusinessName(business);
|
const name = getBusinessName(business);
|
||||||
const domain = getBusinessDomain(business);
|
const domain = getBusinessDomain(business);
|
||||||
|
|
@ -107,7 +55,7 @@ function BusinessCreatedModal({ business, onClose }) {
|
||||||
<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">
|
||||||
<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 text-center">Business Added!</h2>
|
<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>
|
<p className="text-gray-500 text-sm mb-4 font-medium text-center">Your business is ready for onboarding.</p>
|
||||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 mb-6">
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 mb-6">
|
||||||
<div className="flex items-start gap-4">
|
<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">
|
<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">
|
||||||
|
|
@ -135,6 +83,42 @@ function BusinessCreatedModal({ business, onClose }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SalesChannelCard({ channel, disabled, onImport }) {
|
||||||
|
const name = getBusinessName(channel);
|
||||||
|
const domain = getBusinessDomain(channel);
|
||||||
|
const image = getBusinessImage(channel);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition hover:border-indigo-300 hover:shadow-md">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-xl 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-indigo-600">{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() {
|
export default function Businesses() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setActiveBusiness } = useBusiness();
|
const { setActiveBusiness } = useBusiness();
|
||||||
|
|
@ -143,6 +127,7 @@ export default function Businesses() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading');
|
const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading');
|
||||||
const [salesChannelsError, setSalesChannelsError] = useState('');
|
const [salesChannelsError, setSalesChannelsError] = useState('');
|
||||||
|
const [salesChannelQuery, setSalesChannelQuery] = useState('');
|
||||||
const [selectingBusinessId, setSelectingBusinessId] = useState('');
|
const [selectingBusinessId, setSelectingBusinessId] = useState('');
|
||||||
const [creatingSalesChannelId, setCreatingSalesChannelId] = useState('');
|
const [creatingSalesChannelId, setCreatingSalesChannelId] = useState('');
|
||||||
const [createdBusiness, setCreatedBusiness] = useState(null);
|
const [createdBusiness, setCreatedBusiness] = useState(null);
|
||||||
|
|
@ -163,6 +148,17 @@ export default function Businesses() {
|
||||||
salesChannels.filter((channel) => !configuredApplicationIds.has(getChannelId(channel)))
|
salesChannels.filter((channel) => !configuredApplicationIds.has(getChannelId(channel)))
|
||||||
), [configuredApplicationIds, salesChannels]);
|
), [configuredApplicationIds, salesChannels]);
|
||||||
|
|
||||||
|
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 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 || []);
|
||||||
|
|
@ -170,8 +166,7 @@ export default function Businesses() {
|
||||||
|
|
||||||
const loadSalesChannels = useCallback(async () => {
|
const loadSalesChannels = useCallback(async () => {
|
||||||
setSalesChannelsStatus('loading');
|
setSalesChannelsStatus('loading');
|
||||||
const res = await apiClient.get('/api/businesses/sales-channels');
|
const channels = await fetchActiveSalesChannels();
|
||||||
const channels = normalizeChannelsPayload(res.data).filter(isChannelActive);
|
|
||||||
setSalesChannels(channels);
|
setSalesChannels(channels);
|
||||||
setSalesChannelsStatus('success');
|
setSalesChannelsStatus('success');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -195,7 +190,7 @@ export default function Businesses() {
|
||||||
setSalesChannels([]);
|
setSalesChannels([]);
|
||||||
setSalesChannelsStatus('error');
|
setSalesChannelsStatus('error');
|
||||||
setSalesChannelsError(
|
setSalesChannelsError(
|
||||||
salesChannelsRes.reason?.response?.data?.error || 'Active sales channels could not be loaded right now.'
|
salesChannelsRes.reason?.message || 'Active sales channels could not be loaded right now.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -219,15 +214,23 @@ export default function Businesses() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateFromSalesChannel(channel) {
|
async function handleCreateFromSalesChannel(channel) {
|
||||||
const salesChannelId = getChannelId(channel);
|
const applicationId = getChannelId(channel);
|
||||||
if (!salesChannelId) return;
|
if (!applicationId) return;
|
||||||
|
|
||||||
setCreatingSalesChannelId(salesChannelId);
|
if (!channel.websiteUrl) {
|
||||||
|
setSalesChannelsError('A website URL could not be derived from this sales channel. Please use Add Business and enter the URL manually.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatingSalesChannelId(applicationId);
|
||||||
setError('');
|
setError('');
|
||||||
setSalesChannelsError('');
|
setSalesChannelsError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.post('/api/businesses', { salesChannelId });
|
const res = await apiClient.post('/api/businesses', {
|
||||||
|
applicationId,
|
||||||
|
websiteUrl: channel.websiteUrl,
|
||||||
|
});
|
||||||
setCreatedBusiness(res.data);
|
setCreatedBusiness(res.data);
|
||||||
await Promise.all([loadBusinesses(), loadSalesChannels()]);
|
await Promise.all([loadBusinesses(), loadSalesChannels()]);
|
||||||
setSalesChannelsStatus('success');
|
setSalesChannelsStatus('success');
|
||||||
|
|
@ -262,14 +265,14 @@ export default function Businesses() {
|
||||||
|
|
||||||
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-5xl mx-auto">
|
||||||
<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">
|
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">
|
||||||
{businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business'}
|
{businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<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.
|
Import from an active sales channel when available, or use the website URL fallback to scrape manually.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -287,59 +290,7 @@ export default function Businesses() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-10">
|
||||||
<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">
|
|
||||||
{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">
|
<div className="mb-4">
|
||||||
<h2 className="text-lg font-bold text-gray-900 tracking-tight">Configured Businesses</h2>
|
<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>
|
<p className="text-sm text-gray-500 mt-1">Select a business to manage its SMS templates.</p>
|
||||||
|
|
@ -402,19 +353,81 @@ export default function Businesses() {
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-dashed border-gray-300 bg-white p-8 text-center shadow-sm">
|
<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-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>
|
<p className="text-sm text-gray-500">Import from an active sales channel below, or use Add Business to enter a storefront URL manually.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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-indigo-600 hover:text-indigo-700 transition"
|
||||||
|
>
|
||||||
|
Use website URL fallback
|
||||||
|
</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="rounded-2xl 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-indigo-600 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-xl 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>
|
||||||
|
) : (
|
||||||
|
<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 right now.</p>
|
||||||
|
<p className="text-sm text-gray-500">Use Add Business to enter a website URL manually and keep moving.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<RegisterBusinessModal
|
<RegisterBusinessModal onClose={() => { setShowModal(false); loadBusinesses(); }} />
|
||||||
onClose={() => { setShowModal(false); load(); }}
|
|
||||||
initialSalesChannels={availableSalesChannels}
|
|
||||||
initialSalesChannelsStatus={salesChannelsStatus}
|
|
||||||
initialSalesChannelsError={salesChannelsError}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{createdBusiness && <BusinessCreatedModal business={createdBusiness} onClose={() => setCreatedBusiness(null)} />}
|
{createdBusiness && <BusinessCreatedModal business={createdBusiness} onClose={() => setCreatedBusiness(null)} />}
|
||||||
{deleteTarget && (
|
{deleteTarget && (
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,118 @@ function normalizeList(value) {
|
||||||
return Array.isArray(value) ? value.filter(Boolean) : [];
|
return Array.isArray(value) ? value.filter(Boolean) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNonEmptyText(...values) {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDomainName(domain) {
|
||||||
|
if (!domain) return '';
|
||||||
|
if (typeof domain === 'string') return normalizeText(domain);
|
||||||
|
if (typeof domain === 'object') {
|
||||||
|
return firstNonEmptyText(domain.name, domain.domain_url, domain.url, domain.host);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankDomain(domain) {
|
||||||
|
if (!domain || typeof domain !== 'object') return 0;
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
if (domain.is_primary) score += 4;
|
||||||
|
if (domain.verified) score += 2;
|
||||||
|
if (!domain.is_shortlink) score += 1;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPreferredDomain(...sources) {
|
||||||
|
const candidates = [];
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source) continue;
|
||||||
|
|
||||||
|
if (Array.isArray(source)) {
|
||||||
|
for (const item of source) {
|
||||||
|
const name = extractDomainName(item);
|
||||||
|
if (name) candidates.push({ raw: item, name });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = extractDomainName(source);
|
||||||
|
if (name) candidates.push({ raw: source, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidates.length) return '';
|
||||||
|
|
||||||
|
candidates.sort((left, right) => rankDomain(right.raw) - rankDomain(left.raw));
|
||||||
|
return candidates[0].name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWebsiteUrl(value) {
|
||||||
|
const rawValue = normalizeText(value);
|
||||||
|
if (!rawValue) return '';
|
||||||
|
|
||||||
|
const candidate = /^https?:\/\//i.test(rawValue) ? rawValue : `https://${rawValue}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(candidate).toString().replace(/\/$/, '');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChannel(channel = {}) {
|
||||||
|
const domain = pickPreferredDomain(
|
||||||
|
channel.domain,
|
||||||
|
channel.domains,
|
||||||
|
channel.website,
|
||||||
|
channel.website_url,
|
||||||
|
channel.url
|
||||||
|
);
|
||||||
|
const id = String(channel?.salesChannelId || channel?.applicationId || channel?.application_id || channel?.id || channel?._id || '').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...channel,
|
||||||
|
id,
|
||||||
|
salesChannelId: id,
|
||||||
|
applicationId: id,
|
||||||
|
name: firstNonEmptyText(channel.name, channel.display_name, channel.title, channel.slug),
|
||||||
|
domain,
|
||||||
|
websiteUrl: normalizeWebsiteUrl(channel.websiteUrl || channel.website_url || channel.url || domain),
|
||||||
|
isActive: channel.is_active !== false && channel.isActive !== false,
|
||||||
|
logoUrl: firstNonEmptyText(
|
||||||
|
channel.logoUrl,
|
||||||
|
channel.logo?.secure_url,
|
||||||
|
channel.logo?.url,
|
||||||
|
channel.logo,
|
||||||
|
channel.icon?.secure_url,
|
||||||
|
channel.icon?.url,
|
||||||
|
channel.icon,
|
||||||
|
channel.favicon?.secure_url,
|
||||||
|
channel.favicon?.url
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeChannelsPayload(data) {
|
export function normalizeChannelsPayload(data) {
|
||||||
if (Array.isArray(data)) return data;
|
const channels = (
|
||||||
if (Array.isArray(data?.salesChannels)) return data.salesChannels;
|
Array.isArray(data) ? data
|
||||||
if (Array.isArray(data?.channels)) return data.channels;
|
: Array.isArray(data?.salesChannels) ? data.salesChannels
|
||||||
return [];
|
: Array.isArray(data?.channels) ? data.channels
|
||||||
|
: Array.isArray(data?.items) ? data.items
|
||||||
|
: Array.isArray(data?.applications) ? data.applications
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
return channels.map(normalizeChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChannelId(channel) {
|
export function getChannelId(channel) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { getRuntimeCompanyId, getRuntimeExtensionId } from './runtimeContext';
|
||||||
|
import {
|
||||||
|
getChannelId,
|
||||||
|
isChannelActive,
|
||||||
|
normalizeChannelsPayload,
|
||||||
|
} from './businessProfile';
|
||||||
|
|
||||||
|
const FYND_PORTAL_API_BASE = 'https://api.fynd.com';
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWebsiteUrl(value) {
|
||||||
|
const rawValue = normalizeText(value);
|
||||||
|
if (!rawValue) return '';
|
||||||
|
|
||||||
|
const candidate = /^https?:\/\//i.test(rawValue) ? rawValue : `https://${rawValue}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(candidate).toString().replace(/\/$/, '');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCookie(name) {
|
||||||
|
if (typeof document === 'undefined') return '';
|
||||||
|
|
||||||
|
const cookies = `; ${document.cookie || ''}`;
|
||||||
|
const parts = cookies.split(`; ${name}=`);
|
||||||
|
if (parts.length < 2) return '';
|
||||||
|
return parts.pop().split(';').shift() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPortalRequestUrl(companyId, extensionId) {
|
||||||
|
const search = new URLSearchParams({
|
||||||
|
company_id: companyId,
|
||||||
|
page_no: '1',
|
||||||
|
page_size: '100',
|
||||||
|
query: JSON.stringify({ is_active: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (extensionId) {
|
||||||
|
search.set('extension_id', extensionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${FYND_PORTAL_API_BASE}/service/portal/configuration/v1.0/company/${encodeURIComponent(companyId)}/application?${search.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPortalHeaders() {
|
||||||
|
const headers = {
|
||||||
|
accept: 'application/json, text/plain, */*',
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = normalizeText(readCookie('token'));
|
||||||
|
if (token) {
|
||||||
|
headers.authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDerivedWebsiteUrl(channel) {
|
||||||
|
const explicitWebsiteUrl = normalizeWebsiteUrl(
|
||||||
|
channel?.websiteUrl || channel?.domain || channel?.url
|
||||||
|
);
|
||||||
|
|
||||||
|
return explicitWebsiteUrl
|
||||||
|
? { ...channel, websiteUrl: explicitWebsiteUrl }
|
||||||
|
: channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchActiveSalesChannels() {
|
||||||
|
const companyId = getRuntimeCompanyId();
|
||||||
|
const extensionId = getRuntimeExtensionId();
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
throw new Error('Company ID is unavailable for fetching sales channels.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(buildPortalRequestUrl(companyId, extensionId), {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: buildPortalHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = normalizeText(payload?.message || payload?.error)
|
||||||
|
|| `Sales channels could not be fetched (${response.status}).`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeChannelsPayload(payload)
|
||||||
|
.map(withDerivedWebsiteUrl)
|
||||||
|
.filter((channel) => isChannelActive(channel) && getChannelId(channel));
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ const COMPANY_ID_QUERY_KEYS = [
|
||||||
'blt-gtw-fc-cid',
|
'blt-gtw-fc-cid',
|
||||||
];
|
];
|
||||||
const COMPANY_ID_STORAGE_KEY = 'sms_runtime_company_id';
|
const COMPANY_ID_STORAGE_KEY = 'sms_runtime_company_id';
|
||||||
|
const EXTENSION_ID_STORAGE_KEY = 'sms_runtime_extension_id';
|
||||||
|
|
||||||
function getRuntimeUrl() {
|
function getRuntimeUrl() {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
@ -48,6 +49,26 @@ function persistCompanyId(companyId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStoredExtensionId() {
|
||||||
|
if (typeof sessionStorage === 'undefined') return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (sessionStorage.getItem(EXTENSION_ID_STORAGE_KEY) || '').trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistExtensionId(extensionId) {
|
||||||
|
if (!extensionId || typeof sessionStorage === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(EXTENSION_ID_STORAGE_KEY, extensionId);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors; URL/referrer extraction still works for the current request.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getFirstSearchParam(url, keys) {
|
function getFirstSearchParam(url, keys) {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
|
|
||||||
|
|
@ -75,6 +96,20 @@ export function getRuntimeCompanyId() {
|
||||||
return companyId;
|
return companyId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRuntimeExtensionId() {
|
||||||
|
const runtimeUrl = getRuntimeUrl();
|
||||||
|
const referrerUrl = getReferrerUrl();
|
||||||
|
const extensionId = (
|
||||||
|
getPathMatch(runtimeUrl?.pathname || '', /\/extensions\/([^/]+)/i)
|
||||||
|
|| getPathMatch(referrerUrl?.pathname || '', /\/extensions\/([^/]+)/i)
|
||||||
|
|| readStoredExtensionId()
|
||||||
|
|| ''
|
||||||
|
);
|
||||||
|
|
||||||
|
persistExtensionId(extensionId);
|
||||||
|
return extensionId;
|
||||||
|
}
|
||||||
|
|
||||||
export function getRuntimeApplicationId() {
|
export function getRuntimeApplicationId() {
|
||||||
const runtimeUrl = getRuntimeUrl();
|
const runtimeUrl = getRuntimeUrl();
|
||||||
if (!runtimeUrl) return '';
|
if (!runtimeUrl) return '';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ require('dotenv').config();
|
||||||
|
|
||||||
const { setupFdk } = require('@gofynd/fdk-extension-javascript/express');
|
const { setupFdk } = require('@gofynd/fdk-extension-javascript/express');
|
||||||
const { MemoryStorage } = require('@gofynd/fdk-extension-javascript/express/storage');
|
const { MemoryStorage } = require('@gofynd/fdk-extension-javascript/express/storage');
|
||||||
const { createFdkStorage } = require('./postgresFdkStorage');
|
|
||||||
|
|
||||||
function normalizeEnvText(value) {
|
function normalizeEnvText(value) {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
|
@ -23,21 +22,11 @@ function createFdkExtension() {
|
||||||
const apiSecret = normalizeEnvText(process.env.EXTENSION_API_SECRET);
|
const apiSecret = normalizeEnvText(process.env.EXTENSION_API_SECRET);
|
||||||
const baseUrl = normalizeEnvText(process.env.EXTENSION_BASE_URL || process.env.EXTENSION_URL);
|
const baseUrl = normalizeEnvText(process.env.EXTENSION_BASE_URL || process.env.EXTENSION_URL);
|
||||||
const cluster = normalizeEnvText(process.env.EXTENSION_CLUSTER_URL) || 'https://api.fynd.com';
|
const cluster = normalizeEnvText(process.env.EXTENSION_CLUSTER_URL) || 'https://api.fynd.com';
|
||||||
const storageConnectionString = normalizeEnvText(process.env.FDK_STORAGE_CONNECTION_STRING);
|
|
||||||
|
|
||||||
if (!apiKey || !apiSecret || !baseUrl) {
|
if (!apiKey || !apiSecret || !baseUrl) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = createFdkStorage({
|
|
||||||
prefixKey: 'sms_extension_',
|
|
||||||
connectionString: storageConnectionString,
|
|
||||||
}) || new MemoryStorage('sms_extension_');
|
|
||||||
|
|
||||||
if (!storageConnectionString) {
|
|
||||||
console.warn('[FDK] FDK_STORAGE_CONNECTION_STRING is not set; falling back to in-memory session storage.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return setupFdk({
|
return setupFdk({
|
||||||
api_key: apiKey,
|
api_key: apiKey,
|
||||||
|
|
@ -52,8 +41,7 @@ function createFdkExtension() {
|
||||||
console.log(`[FDK] uninstall callback received for company ${companyId || 'unknown'}`);
|
console.log(`[FDK] uninstall callback received for company ${companyId || 'unknown'}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
storage,
|
storage: new MemoryStorage('sms_extension_'),
|
||||||
access_mode: 'offline',
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[FDK] Failed to initialize FDK: ${error.message}`);
|
console.warn(`[FDK] Failed to initialize FDK: ${error.message}`);
|
||||||
|
|
@ -63,20 +51,7 @@ function createFdkExtension() {
|
||||||
|
|
||||||
const fdkExtension = createFdkExtension();
|
const fdkExtension = createFdkExtension();
|
||||||
|
|
||||||
async function getPlatformClientForCompany(companyId) {
|
|
||||||
if (!fdkExtension) {
|
|
||||||
throw new Error('FDK is not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizeEnvText(companyId)) {
|
|
||||||
throw new Error('companyId is required to fetch a platform client');
|
|
||||||
}
|
|
||||||
|
|
||||||
return fdkExtension.getPlatformClient(companyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
fdkExtension,
|
fdkExtension,
|
||||||
isFdkConfigured: Boolean(fdkExtension),
|
isFdkConfigured: Boolean(fdkExtension),
|
||||||
getPlatformClientForCompany,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"openai": "^4.28.0",
|
"openai": "^4.28.0",
|
||||||
"pg": "^8.20.0",
|
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1669,96 +1668,6 @@
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg": {
|
|
||||||
"version": "8.20.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
|
||||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"pg-connection-string": "^2.12.0",
|
|
||||||
"pg-pool": "^3.13.0",
|
|
||||||
"pg-protocol": "^1.13.0",
|
|
||||||
"pg-types": "2.2.0",
|
|
||||||
"pgpass": "1.0.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 16.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"pg-cloudflare": "^1.3.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"pg-native": ">=3.0.1"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"pg-native": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg-cloudflare": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/pg-connection-string": {
|
|
||||||
"version": "2.12.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
|
||||||
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/pg-int8": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg-pool": {
|
|
||||||
"version": "3.13.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
|
||||||
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"pg": ">=8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg-protocol": {
|
|
||||||
"version": "1.13.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
|
||||||
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/pg-types": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"pg-int8": "1.0.1",
|
|
||||||
"postgres-array": "~2.0.0",
|
|
||||||
"postgres-bytea": "~1.0.0",
|
|
||||||
"postgres-date": "~1.0.4",
|
|
||||||
"postgres-interval": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pgpass": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"split2": "^4.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
|
@ -1772,45 +1681,6 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postgres-array": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-bytea": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-date": {
|
|
||||||
"version": "1.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
|
||||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-interval": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"xtend": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/process-nextick-args": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
|
@ -2137,15 +2007,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/split2": {
|
|
||||||
"version": "4.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
|
||||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/stack-trace": {
|
"node_modules/stack-trace": {
|
||||||
"version": "0.0.10",
|
"version": "0.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"openai": "^4.28.0",
|
"openai": "^4.28.0",
|
||||||
"pg": "^8.20.0",
|
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
const { Pool } = require('pg');
|
|
||||||
const BaseStorage = require('@gofynd/fdk-extension-javascript/express/storage/base_storage');
|
|
||||||
|
|
||||||
const DEFAULT_TABLE_NAME = 'fdk session storage';
|
|
||||||
|
|
||||||
function normalizeEnvText(value) {
|
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function quoteIdentifier(identifier) {
|
|
||||||
return `"${String(identifier).replace(/"/g, '""')}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldUseSsl(connectionString) {
|
|
||||||
const normalized = normalizeEnvText(connectionString).toLowerCase();
|
|
||||||
if (!normalized) return false;
|
|
||||||
if (normalized.includes('sslmode=disable')) return false;
|
|
||||||
if (normalized.includes('sslmode=require')) return true;
|
|
||||||
return !normalized.includes('localhost') && !normalized.includes('127.0.0.1');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPool(connectionString) {
|
|
||||||
if (!globalThis.__smsExtensionFdkStoragePool) {
|
|
||||||
globalThis.__smsExtensionFdkStoragePool = new Pool({
|
|
||||||
connectionString,
|
|
||||||
max: 3,
|
|
||||||
idleTimeoutMillis: 30000,
|
|
||||||
ssl: shouldUseSsl(connectionString) ? { rejectUnauthorized: false } : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return globalThis.__smsExtensionFdkStoragePool;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeValue(value) {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseValue(rawValue) {
|
|
||||||
if (!rawValue) return null;
|
|
||||||
if (typeof rawValue === 'object') return rawValue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(rawValue);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PostgresFdkStorage extends BaseStorage {
|
|
||||||
constructor({ prefixKey, connectionString, tableName = DEFAULT_TABLE_NAME }) {
|
|
||||||
super(prefixKey);
|
|
||||||
|
|
||||||
if (!normalizeEnvText(connectionString)) {
|
|
||||||
throw new Error('FDK_STORAGE_CONNECTION_STRING is required for Postgres FDK storage');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pool = getPool(connectionString);
|
|
||||||
this.tableIdentifier = quoteIdentifier(tableName);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildKey(key) {
|
|
||||||
return `${this.prefixKey}${key}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(key) {
|
|
||||||
const storageKey = this.buildKey(key);
|
|
||||||
const { rows } = await this.pool.query(
|
|
||||||
`SELECT value, expires_at
|
|
||||||
FROM ${this.tableIdentifier}
|
|
||||||
WHERE storage_key = $1
|
|
||||||
ORDER BY updated_at DESC NULLS LAST, created_at DESC NULLS LAST, id DESC
|
|
||||||
LIMIT 1`,
|
|
||||||
[storageKey]
|
|
||||||
);
|
|
||||||
|
|
||||||
const row = rows[0];
|
|
||||||
if (!row) return null;
|
|
||||||
|
|
||||||
const expiresAt = row.expires_at ? new Date(row.expires_at) : null;
|
|
||||||
if (expiresAt && expiresAt.getTime() <= Date.now()) {
|
|
||||||
await this.del(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseValue(row.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async set(key, value) {
|
|
||||||
await this.writeRecord(key, value, null);
|
|
||||||
return 'OK';
|
|
||||||
}
|
|
||||||
|
|
||||||
async setex(key, value, ttl) {
|
|
||||||
const expiresAt = Number.isFinite(ttl)
|
|
||||||
? new Date(Date.now() + ttl * 1000)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await this.writeRecord(key, value, expiresAt);
|
|
||||||
return 'OK';
|
|
||||||
}
|
|
||||||
|
|
||||||
async del(key) {
|
|
||||||
const storageKey = this.buildKey(key);
|
|
||||||
await this.pool.query(
|
|
||||||
`DELETE FROM ${this.tableIdentifier}
|
|
||||||
WHERE storage_key = $1`,
|
|
||||||
[storageKey]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeRecord(key, value, expiresAt) {
|
|
||||||
const storageKey = this.buildKey(key);
|
|
||||||
const serializedValue = serializeValue(value);
|
|
||||||
|
|
||||||
const updateResult = await this.pool.query(
|
|
||||||
`UPDATE ${this.tableIdentifier}
|
|
||||||
SET value = $2,
|
|
||||||
expires_at = $3,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE storage_key = $1`,
|
|
||||||
[storageKey, serializedValue, expiresAt]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (updateResult.rowCount > 0) return;
|
|
||||||
|
|
||||||
await this.pool.query(
|
|
||||||
`INSERT INTO ${this.tableIdentifier} (storage_key, value, expires_at)
|
|
||||||
VALUES ($1, $2, $3)`,
|
|
||||||
[storageKey, serializedValue, expiresAt]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFdkStorage({ prefixKey, connectionString }) {
|
|
||||||
if (!normalizeEnvText(connectionString)) return null;
|
|
||||||
|
|
||||||
return new PostgresFdkStorage({
|
|
||||||
prefixKey,
|
|
||||||
connectionString,
|
|
||||||
tableName: DEFAULT_TABLE_NAME,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
DEFAULT_TABLE_NAME,
|
|
||||||
PostgresFdkStorage,
|
|
||||||
createFdkStorage,
|
|
||||||
};
|
|
||||||
|
|
@ -13,7 +13,6 @@ const {
|
||||||
deleteBusinessFiles,
|
deleteBusinessFiles,
|
||||||
} = require('../services/pixelbin');
|
} = require('../services/pixelbin');
|
||||||
const DEFAULT_EVENTS = require('../config/defaultEvents');
|
const DEFAULT_EVENTS = require('../config/defaultEvents');
|
||||||
const { getPlatformClientForCompany } = require('../fdk');
|
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
|
||||||
const MERCHANT_ID = () => process.env.MERCHANT_ID;
|
const MERCHANT_ID = () => process.env.MERCHANT_ID;
|
||||||
|
|
@ -216,69 +215,6 @@ function normalizeWebsiteUrl(value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractDomainName(domain) {
|
|
||||||
if (!domain) return '';
|
|
||||||
if (typeof domain === 'string') return normalizeText(domain);
|
|
||||||
if (typeof domain === 'object') {
|
|
||||||
return firstNonEmptyText(domain.name, domain.domain_url, domain.url, domain.host);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function rankDomain(domain) {
|
|
||||||
if (!domain || typeof domain !== 'object') return 0;
|
|
||||||
let score = 0;
|
|
||||||
if (domain.is_primary) score += 4;
|
|
||||||
if (domain.verified) score += 2;
|
|
||||||
if (!domain.is_shortlink) score += 1;
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickPreferredDomain(...sources) {
|
|
||||||
const candidates = [];
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
if (!source) continue;
|
|
||||||
if (Array.isArray(source)) {
|
|
||||||
for (const item of source) {
|
|
||||||
const name = extractDomainName(item);
|
|
||||||
if (name) candidates.push({ raw: item, name });
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = extractDomainName(source);
|
|
||||||
if (name) candidates.push({ raw: source, name });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!candidates.length) return '';
|
|
||||||
|
|
||||||
candidates.sort((left, right) => rankDomain(right.raw) - rankDomain(left.raw));
|
|
||||||
return candidates[0].name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSalesChannel(application = {}, domains = []) {
|
|
||||||
const domainName = pickPreferredDomain(application.domain, application.domains, domains);
|
|
||||||
const id = normalizeScopeId(application._id || application.id || application.application_id);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
salesChannelId: id,
|
|
||||||
applicationId: id,
|
|
||||||
name: firstNonEmptyText(application.name, application.display_name, application.slug),
|
|
||||||
domain: domainName,
|
|
||||||
websiteUrl: normalizeWebsiteUrl(domainName),
|
|
||||||
isActive: application.is_active !== false,
|
|
||||||
logoUrl: firstNonEmptyText(
|
|
||||||
application.logo?.secure_url,
|
|
||||||
application.logo?.url,
|
|
||||||
application.logo,
|
|
||||||
application.favicon?.secure_url,
|
|
||||||
application.favicon?.url
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBusinessPreviewSummary(source = {}) {
|
function getBusinessPreviewSummary(source = {}) {
|
||||||
const taglines = Array.isArray(source?.taglines)
|
const taglines = Array.isArray(source?.taglines)
|
||||||
? source.taglines.map((tagline) => normalizeText(tagline)).filter(Boolean)
|
? source.taglines.map((tagline) => normalizeText(tagline)).filter(Boolean)
|
||||||
|
|
@ -303,58 +239,6 @@ function mergeBusinessSummary(baseBusiness = {}, context = null) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPlatformClient(req, companyId) {
|
|
||||||
if (req?.platformClient) return req.platformClient;
|
|
||||||
return getPlatformClientForCompany(companyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listAllSalesChannels(platformClient, query = '') {
|
|
||||||
const items = [];
|
|
||||||
const pageSize = 100;
|
|
||||||
let pageNo = 1;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const response = await platformClient.configuration.getApplications({
|
|
||||||
pageNo,
|
|
||||||
pageSize,
|
|
||||||
q: normalizeText(query) || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const batch = Array.isArray(response?.items) ? response.items : [];
|
|
||||||
items.push(...batch);
|
|
||||||
|
|
||||||
const totalPages = Number(response?.page?.total_page) || Number(response?.page?.total_pages) || 0;
|
|
||||||
if (!batch.length || batch.length < pageSize || (totalPages && pageNo >= totalPages)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
pageNo += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSalesChannelDetails(platformClient, salesChannelId) {
|
|
||||||
const applicationClient = platformClient.application(salesChannelId).configuration;
|
|
||||||
|
|
||||||
const [applicationResponse, domainsResponse] = await Promise.allSettled([
|
|
||||||
applicationClient.getApplicationById(),
|
|
||||||
applicationClient.getDomains(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (applicationResponse.status !== 'fulfilled' && domainsResponse.status !== 'fulfilled') {
|
|
||||||
const error = applicationResponse.reason || domainsResponse.reason || new Error('Unable to fetch sales channel details');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const application = applicationResponse.status === 'fulfilled'
|
|
||||||
? applicationResponse.value
|
|
||||||
: { _id: salesChannelId };
|
|
||||||
const domains = domainsResponse.status === 'fulfilled' ? domainsResponse.value?.domains || [] : [];
|
|
||||||
|
|
||||||
return normalizeSalesChannel(application, domains);
|
|
||||||
}
|
|
||||||
|
|
||||||
const LEGACY_DEFAULT_EVENT_SLUGS = new Set(['confirmed', 'pack', 'cancelled']);
|
const LEGACY_DEFAULT_EVENT_SLUGS = new Set(['confirmed', 'pack', 'cancelled']);
|
||||||
const EVENT_TEMPLATE_FALLBACKS = {
|
const EVENT_TEMPLATE_FALLBACKS = {
|
||||||
bag_confirmed: ['confirmed'],
|
bag_confirmed: ['confirmed'],
|
||||||
|
|
@ -924,39 +808,7 @@ router.get('/', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/businesses/sales-channels
|
// POST /api/businesses — create new business from websiteUrl with optional applicationId
|
||||||
router.get('/sales-channels', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const companyId = getCompanyId(req);
|
|
||||||
if (!companyId) {
|
|
||||||
throw createHttpError(400, 'companyId is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformClient = await getPlatformClient(req, companyId);
|
|
||||||
const applications = await listAllSalesChannels(platformClient);
|
|
||||||
const salesChannels = applications
|
|
||||||
.map((application) => normalizeSalesChannel(application))
|
|
||||||
.filter((channel) => channel.id && channel.name && channel.isActive)
|
|
||||||
.sort((left, right) => left.name.localeCompare(right.name));
|
|
||||||
|
|
||||||
res.json({ salesChannels });
|
|
||||||
} catch (err) {
|
|
||||||
if (err.message === 'FDK is not configured') {
|
|
||||||
return res.status(503).json({
|
|
||||||
error: 'Sales channel fetch is unavailable',
|
|
||||||
code: 'SALES_CHANNEL_FETCH_UNAVAILABLE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
sendRouteError(res, createHttpError(
|
|
||||||
err.status || 502,
|
|
||||||
err.message || 'Failed to fetch sales channels',
|
|
||||||
err.code ? { code: err.code, details: err.details } : {}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/businesses — create new business from sales channel or websiteUrl fallback
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const merchantId = getCompanyId(req);
|
const merchantId = getCompanyId(req);
|
||||||
|
|
@ -964,51 +816,21 @@ router.post('/', async (req, res) => {
|
||||||
throw createHttpError(400, 'companyId is required');
|
throw createHttpError(400, 'companyId is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedSalesChannelId = normalizeScopeId(
|
const applicationId = normalizeScopeId(
|
||||||
req.body?.salesChannelId
|
req.body?.applicationId
|
||||||
|| req.body?.applicationId
|
|
||||||
|| req.body?.application_id
|
|| req.body?.application_id
|
||||||
|
|| req.body?.salesChannelId
|
||||||
|
|| getApplicationId(req)
|
||||||
);
|
);
|
||||||
let websiteUrl = normalizeWebsiteUrl(req.body?.websiteUrl);
|
const websiteUrl = normalizeWebsiteUrl(req.body?.websiteUrl);
|
||||||
let salesChannel = null;
|
|
||||||
|
|
||||||
if (!requestedSalesChannelId && !websiteUrl) {
|
if (!websiteUrl) {
|
||||||
throw createHttpError(
|
throw createHttpError(
|
||||||
400,
|
400,
|
||||||
'Either salesChannelId or websiteUrl is required',
|
'websiteUrl is required',
|
||||||
{ code: 'MISSING_BUSINESS_SOURCE' }
|
{ code: 'MISSING_WEBSITE_URL' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestedSalesChannelId) {
|
|
||||||
try {
|
|
||||||
const platformClient = await getPlatformClient(req, merchantId);
|
|
||||||
salesChannel = await getSalesChannelDetails(platformClient, requestedSalesChannelId);
|
|
||||||
} catch (error) {
|
|
||||||
if (!websiteUrl) {
|
|
||||||
throw createHttpError(
|
|
||||||
error.message === 'FDK is not configured' ? 503 : 502,
|
|
||||||
'Unable to fetch sales channel details',
|
|
||||||
{
|
|
||||||
code: 'SALES_CHANNEL_DETAILS_UNAVAILABLE',
|
|
||||||
details: { salesChannelId: requestedSalesChannelId, reason: error.message },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
websiteUrl = websiteUrl || salesChannel?.websiteUrl || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!websiteUrl) {
|
|
||||||
throw createHttpError(
|
|
||||||
422,
|
|
||||||
'A website URL could not be derived from the selected sales channel. Please enter it manually.',
|
|
||||||
{ code: 'MISSING_SALES_CHANNEL_WEBSITE' }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const applicationId = requestedSalesChannelId || getApplicationId(req);
|
|
||||||
const businesses = await getIndex(merchantId);
|
const businesses = await getIndex(merchantId);
|
||||||
|
|
||||||
if (applicationId && businesses.some((business) => normalizeScopeId(business.applicationId) === applicationId)) {
|
if (applicationId && businesses.some((business) => normalizeScopeId(business.applicationId) === applicationId)) {
|
||||||
|
|
@ -1070,14 +892,7 @@ router.post('/', async (req, res) => {
|
||||||
});
|
});
|
||||||
await saveIndex(merchantId, businesses);
|
await saveIndex(merchantId, businesses);
|
||||||
|
|
||||||
res.json({
|
res.json(contextJson);
|
||||||
...contextJson,
|
|
||||||
salesChannel: salesChannel ? {
|
|
||||||
salesChannelId: salesChannel.id,
|
|
||||||
name: salesChannel.name,
|
|
||||||
domain: salesChannel.domain,
|
|
||||||
} : null,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Create business error:', err.message);
|
console.error('Create business error:', err.message);
|
||||||
sendRouteError(res, err);
|
sendRouteError(res, err);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user