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:
Ritul Jadhav 2026-03-31 12:35:11 +05:30
parent b8451e7df8
commit b5ebb830d8
11 changed files with 411 additions and 822 deletions

View File

@ -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

View File

@ -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&apos;ll scrape it directly. Enter the storefront website URL and we&apos;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' ? (

View File

@ -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 && (

View File

@ -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) {

View File

@ -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));
}

View File

@ -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 '';

View File

@ -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,
}; };

139
server/package-lock.json generated
View File

@ -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",

View File

@ -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": {

View File

@ -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,
};

View File

@ -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);