Setting provider name auto

This commit is contained in:
Ritul Jadhav 2026-04-09 15:30:14 +05:30
parent 6448ad1e32
commit 232d734c98
7 changed files with 326 additions and 72 deletions

View File

@ -1,5 +1,6 @@
import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
import { getBusinessImage } from '../utils/businessProfile';
const SVG_ICONS = { const SVG_ICONS = {
analytics: ( analytics: (
@ -64,26 +65,6 @@ function StageMarker({ done, active, enabled }) {
return <span className="inline-block h-3 w-3 rounded-full bg-refresh-active" />; return <span className="inline-block h-3 w-3 rounded-full bg-refresh-active" />;
} }
function normalizeText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function getSidebarBusinessImage(business) {
const brandingLogos = business?.scrapeArtifacts?.json?.branding?.logos;
const primaryLogo = Array.isArray(brandingLogos)
? brandingLogos.find((entry) => normalizeText(entry))
: '';
if (primaryLogo) return primaryLogo;
return (
normalizeText(business?.logoUrl)
|| normalizeText(business?.imageUrl)
|| (Array.isArray(business?.relevantImagePaths)
? business.relevantImagePaths.find((entry) => normalizeText(entry)) || ''
: '')
);
}
export default function Sidebar({ onOpenReview, reviewLoading = false, reviewError = '' }) { export default function Sidebar({ onOpenReview, reviewLoading = false, reviewError = '' }) {
const { const {
activeBusiness, activeBusiness,
@ -95,7 +76,7 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
} = useBusiness(); } = useBusiness();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const businessImage = getSidebarBusinessImage(activeBusiness); const businessImage = getBusinessImage(activeBusiness);
const analyticsPath = `/${activeBusinessId}/analytics`; const analyticsPath = `/${activeBusinessId}/analytics`;
const globalSmsPath = `/${activeBusinessId}/global-sms`; const globalSmsPath = `/${activeBusinessId}/global-sms`;

View File

@ -4,6 +4,7 @@ import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']); const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
const PENDING_SENDER_ID_PROFILE_NAME = 'Pending Sender ID';
function formatUpdatedAt(value) { function formatUpdatedAt(value) {
if (!value) return 'Not updated yet'; if (!value) return 'Not updated yet';
@ -57,6 +58,12 @@ function getProfileSummary(profile) {
return parts.join(' • ') || 'Profile saved. Complete the remaining setup fields to continue.'; return parts.join(' • ') || 'Profile saved. Complete the remaining setup fields to continue.';
} }
function isPendingSenderIdProfile(profile) {
const normalizedName = String(profile?.name || '').trim();
const senderId = String(profile?.provider?.senderId || '').trim();
return (profile?.isAutoNamed === true && !senderId) || normalizedName === PENDING_SENDER_ID_PROFILE_NAME;
}
function DeleteProfileModal({ preview, deleting, onCancel, onConfirm }) { function DeleteProfileModal({ preview, deleting, onCancel, onConfirm }) {
if (!preview) return null; if (!preview) return null;
@ -133,7 +140,6 @@ export default function GlobalSms() {
const [savingInputs, setSavingInputs] = useState(false); const [savingInputs, setSavingInputs] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [formName, setFormName] = useState('');
const [formCurl, setFormCurl] = useState(''); const [formCurl, setFormCurl] = useState('');
const [formSetActive, setFormSetActive] = useState(true); const [formSetActive, setFormSetActive] = useState(true);
const [inputForm, setInputForm] = useState({}); const [inputForm, setInputForm] = useState({});
@ -146,10 +152,12 @@ export default function GlobalSms() {
() => profiles.find((profile) => profile.id === activeProfileId) || null, () => profiles.find((profile) => profile.id === activeProfileId) || null,
[profiles, activeProfileId], [profiles, activeProfileId],
); );
const missingInputs = activeProfile?.executionReadiness?.missingProfileInputs || []; const missingInputs = useMemo(
() => activeProfile?.executionReadiness?.missingProfileInputs || [],
[activeProfile?.executionReadiness?.missingProfileInputs],
);
const hasProfiles = profiles.length > 0; const hasProfiles = profiles.length > 0;
const eventsPath = `/${businessId}/events`; const eventsPath = `/${businessId}/events`;
const analyticsPath = `/${businessId}/analytics`;
const loadProfiles = useCallback(async () => { const loadProfiles = useCallback(async () => {
try { try {
@ -198,7 +206,7 @@ export default function GlobalSms() {
async function handleSubmit(event) { async function handleSubmit(event) {
event.preventDefault(); event.preventDefault();
if (!formName.trim() || !formCurl.trim()) return; if (!formCurl.trim()) return;
setSaving(true); setSaving(true);
setError(''); setError('');
@ -207,12 +215,10 @@ export default function GlobalSms() {
try { try {
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles`, { await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles`, {
name: formName.trim(),
rawCurl: formCurl.trim(), rawCurl: formCurl.trim(),
setActive: formSetActive, setActive: formSetActive,
}); });
setFormName('');
setFormCurl(''); setFormCurl('');
setFormSetActive(true); setFormSetActive(true);
setSuccess('Profile created successfully.'); setSuccess('Profile created successfully.');
@ -387,7 +393,12 @@ export default function GlobalSms() {
{activeProfile ? ( {activeProfile ? (
<div className={`rounded-lg border p-5 ${activeProfile.executionReadiness?.isSetupComplete ? 'border-primary-blue bg-white' : 'border-gray-200 bg-white'}`}> <div className={`rounded-lg border p-5 ${activeProfile.executionReadiness?.isSetupComplete ? 'border-primary-blue bg-white' : 'border-gray-200 bg-white'}`}>
<div className="mb-4 flex items-center gap-3"> <div className="mb-4 flex items-center gap-3">
<h3 className="text-lg font-bold text-text-primary">Active Setup: {activeProfile.name}</h3> <h3 className="text-lg font-bold text-text-primary">
Active Setup:{' '}
<span className={isPendingSenderIdProfile(activeProfile) ? 'text-error-text' : 'text-text-primary'}>
{activeProfile.name}
</span>
</h3>
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-bold uppercase tracking-wide text-gray-700"> <span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-bold uppercase tracking-wide text-gray-700">
{activeProfile.executionReadiness?.isSetupComplete ? 'Setup Complete' : 'Missing Information'} {activeProfile.executionReadiness?.isSetupComplete ? 'Setup Complete' : 'Missing Information'}
</span> </span>
@ -490,7 +501,7 @@ export default function GlobalSms() {
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="mb-3 flex flex-wrap items-center gap-3"> <div className="mb-3 flex flex-wrap items-center gap-3">
<h3 className="truncate text-base font-bold text-text-primary">{profile.name}</h3> <h3 className={`truncate text-base font-bold ${isPendingSenderIdProfile(profile) ? 'text-error-text' : 'text-text-primary'}`}>{profile.name}</h3>
{isActive && ( {isActive && (
<span className="rounded-full border border-indigo-200 bg-indigo-50 px-2.5 py-1 text-[11px] font-bold uppercase tracking-wider text-primary-dark"> <span className="rounded-full border border-indigo-200 bg-indigo-50 px-2.5 py-1 text-[11px] font-bold uppercase tracking-wider text-primary-dark">
Active Profile Active Profile
@ -572,18 +583,6 @@ export default function GlobalSms() {
)} )}
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="mb-1.5 block text-sm font-semibold text-text-primary">Profile Name</label>
<input
type="text"
value={formName}
onChange={(event) => setFormName(event.target.value)}
placeholder="e.g. Production SMS"
className="w-full rounded-lg border border-border-main bg-white px-4 py-2 text-sm text-text-primary transition focus:outline-none focus:ring-2 focus:ring-primary-blue"
required
/>
</div>
<div> <div>
<label className="mb-1.5 block text-sm font-semibold text-text-primary">Provider cURL Command</label> <label className="mb-1.5 block text-sm font-semibold text-text-primary">Provider cURL Command</label>
<textarea <textarea
@ -594,6 +593,9 @@ export default function GlobalSms() {
required required
spellCheck="false" spellCheck="false"
/> />
<p className="mt-2 text-xs font-medium text-text-muted">
Profile name is generated automatically from Sender ID. If Sender ID is not detected yet, it stays as <span className="text-error-text">{PENDING_SENDER_ID_PROFILE_NAME}</span> until completed.
</p>
</div> </div>
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">

View File

@ -4,6 +4,13 @@ import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']); const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
const PENDING_SENDER_ID_PROFILE_NAME = 'Pending Sender ID';
function isPendingSenderIdProfile(profile) {
const normalizedName = String(profile?.name || '').trim();
const senderId = String(profile?.provider?.senderId || '').trim();
return (profile?.isAutoNamed === true && !senderId) || normalizedName === PENDING_SENDER_ID_PROFILE_NAME;
}
function normalizeCurlForDisplay(value) { function normalizeCurlForDisplay(value) {
if (!value) return ''; if (!value) return '';
@ -448,7 +455,7 @@ export default function Providers() {
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900">{profile.name}</p> <p className={`truncate text-sm font-semibold ${isPendingSenderIdProfile(profile) ? 'text-error-text' : 'text-gray-900'}`}>{profile.name}</p>
<p className="mt-1 text-sm leading-relaxed text-gray-500">{getProfileSummary(profile)}</p> <p className="mt-1 text-sm leading-relaxed text-gray-500">{getProfileSummary(profile)}</p>
</div> </div>
<div className="flex shrink-0 flex-wrap justify-end gap-2"> <div className="flex shrink-0 flex-wrap justify-end gap-2">

View File

@ -14,6 +14,16 @@ function firstNonEmptyText(...values) {
return ''; return '';
} }
function getScrapedLogoUrl(entity = {}) {
const branding = entity?.scrapeArtifacts?.json?.branding;
return firstNonEmptyText(
branding?.primaryLogoUrl,
Array.isArray(branding?.logoCandidates) ? branding.logoCandidates[0] : '',
Array.isArray(branding?.logos) ? branding.logos[0] : ''
);
}
function extractDomainName(domain) { function extractDomainName(domain) {
if (!domain) return ''; if (!domain) return '';
if (typeof domain === 'string') return normalizeText(domain); if (typeof domain === 'string') return normalizeText(domain);
@ -170,11 +180,14 @@ export function getBusinessTagline(entity) {
export function getBusinessImage(entity) { export function getBusinessImage(entity) {
const relevantImage = normalizeList(entity?.relevantImagePaths)[0]; const relevantImage = normalizeList(entity?.relevantImagePaths)[0];
if (relevantImage) return relevantImage; const scrapedLogo = getScrapedLogoUrl(entity);
return ( return (
entity?.imageUrl entity?.logoUrl
|| entity?.logoUrl || scrapedLogo
|| entity?.imageUrl
|| entity?.previewImagePath
|| relevantImage
|| entity?.brandImageUrl || entity?.brandImageUrl
|| entity?.image || entity?.image
|| '' || ''

View File

@ -143,6 +143,7 @@ const RUNTIME_TOKEN_LABELS = {
}; };
const CURL_DATA_FLAGS = new Set(['--data', '--data-raw', '--data-binary', '-d']); const CURL_DATA_FLAGS = new Set(['--data', '--data-raw', '--data-binary', '-d']);
const DETERMINISTIC_SENDER_ID_KEYS = ['sender_id', 'senderId', 'sender', 'sender_code', 'senderCode']; const DETERMINISTIC_SENDER_ID_KEYS = ['sender_id', 'senderId', 'sender', 'sender_code', 'senderCode'];
const PENDING_SENDER_ID_PROFILE_NAME = 'Pending Sender ID';
function createHttpError(status, message, extra = {}) { function createHttpError(status, message, extra = {}) {
const err = new Error(message); const err = new Error(message);
@ -218,6 +219,23 @@ function validateSenderId(senderId) {
return null; return null;
} }
function buildAutomaticProfileName(source = {}) {
const senderId = normalizeSenderId(
source?.provider?.senderId
|| source?.senderId
);
return senderId || PENDING_SENDER_ID_PROFILE_NAME;
}
function syncAutomaticProfileName(profile = {}) {
if (profile?.isAutoNamed !== true) return profile;
profile.name = buildAutomaticProfileName(profile);
profile.isAutoNamed = true;
return profile;
}
function getSenderIdFromStructuredValue(value) { function getSenderIdFromStructuredValue(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return ''; if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
@ -558,9 +576,10 @@ function getProfileDisplayValue(profile = {}, key) {
} }
function serializeProfile(profile = {}) { function serializeProfile(profile = {}) {
const hydratedProfile = hydrateProfile(profile); const hydratedProfile = syncAutomaticProfileName(hydrateProfile(profile));
return { return {
...hydratedProfile, ...hydratedProfile,
isAutoNamed: hydratedProfile.isAutoNamed === true,
rawCurl: undefined, rawCurl: undefined,
rawCurlTemplate: undefined, rawCurlTemplate: undefined,
profileInputValues: undefined, profileInputValues: undefined,
@ -607,7 +626,7 @@ function sanitizeStoredCurlAnalysis(profile = {}) {
} }
function persistableProfile(profile = {}) { function persistableProfile(profile = {}) {
const hydratedProfile = hydrateProfile(profile); const hydratedProfile = syncAutomaticProfileName(hydrateProfile(profile));
const normalizedAuthKey = firstNonEmptyResolvedText( const normalizedAuthKey = firstNonEmptyResolvedText(
hydratedProfile.profileInputValues?.authKey, hydratedProfile.profileInputValues?.authKey,
hydratedProfile.provider?.authKey, hydratedProfile.provider?.authKey,
@ -616,6 +635,7 @@ function persistableProfile(profile = {}) {
return { return {
id: hydratedProfile.id, id: hydratedProfile.id,
name: normalizeText(hydratedProfile.name), name: normalizeText(hydratedProfile.name),
isAutoNamed: hydratedProfile.isAutoNamed === true,
rawCurl: getStoredCurlTemplate(hydratedProfile), rawCurl: getStoredCurlTemplate(hydratedProfile),
isDefault: hydratedProfile.isDefault === true, isDefault: hydratedProfile.isDefault === true,
provider: { provider: {
@ -791,22 +811,124 @@ function normalizeUrlList(value) {
}); });
} }
const LOGO_URL_POSITIVE_REGEX = /(?:^|[\/_.-])(logo|wordmark|brandmark|site-logo|header-logo)(?:[\/_.-]|$)/i;
const LOGO_URL_ICON_REGEX = /(?:^|[\/_.-])(favicon|apple-touch-icon|mask-icon|mstile|icon)(?:[\/_.-]|$)/i;
const LOGO_URL_NEGATIVE_REGEX = /(hero|banner|product|products|collection|collections|slide|carousel|thumbnail|thumb|promo|cover|background)/i;
const LOGO_URL_SOCIAL_REGEX = /(facebook|instagram|twitter|linkedin|youtube|pinterest|avatar|profile)/i;
function isAbsoluteHttpUrl(value) {
const normalized = normalizeText(value);
if (!normalized) return false;
try {
const url = new URL(normalized);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function scoreLogoCandidateUrl(url, sources = new Set()) {
const normalized = normalizeText(url).toLowerCase();
if (!isAbsoluteHttpUrl(normalized)) return -100;
let score = 0;
if (sources.has('branding')) score += 70;
if (sources.has('model')) score += 45;
if (sources.has('relevant') && (LOGO_URL_POSITIVE_REGEX.test(normalized) || LOGO_URL_ICON_REGEX.test(normalized))) score += 12;
if (sources.has('crawl') && (LOGO_URL_POSITIVE_REGEX.test(normalized) || LOGO_URL_ICON_REGEX.test(normalized))) score += 6;
if (LOGO_URL_POSITIVE_REGEX.test(normalized)) score += 30;
if (LOGO_URL_ICON_REGEX.test(normalized)) score += 18;
if (normalized.endsWith('.svg') || normalized.includes('.svg?')) score += 12;
if (normalized.endsWith('.png') || normalized.includes('.png?')) score += 6;
if (normalized.endsWith('.webp') || normalized.includes('.webp?')) score += 3;
if (LOGO_URL_NEGATIVE_REGEX.test(normalized)) score -= 35;
if (LOGO_URL_SOCIAL_REGEX.test(normalized)) score -= 25;
if (/\/products?\//i.test(normalized) || /\/collections?\//i.test(normalized)) score -= 20;
return score;
}
function collectLogoCandidates(crawlSummary = {}, brandContext = {}) {
const candidatesByUrl = new Map();
let order = 0;
function append(values, source) {
normalizeUrlList(values).forEach((url) => {
if (!isAbsoluteHttpUrl(url)) return;
if (!candidatesByUrl.has(url)) {
candidatesByUrl.set(url, {
url,
sources: new Set(),
order,
});
order += 1;
}
candidatesByUrl.get(url).sources.add(source);
});
}
append([brandContext?.logoUrl], 'model');
append([crawlSummary?.branding?.primaryLogoUrl], 'branding');
append(crawlSummary?.branding?.logos, 'branding');
append(crawlSummary?.branding?.logoCandidates, 'branding');
append(brandContext?.relevantImageUrls, 'relevant');
append(crawlSummary?.topImages, 'crawl');
return Array.from(candidatesByUrl.values())
.map((candidate) => ({
...candidate,
score: scoreLogoCandidateUrl(candidate.url, candidate.sources),
}))
.sort((left, right) => right.score - left.score || left.order - right.order);
}
function selectCanonicalLogoSourceUrl(crawlSummary = {}, brandContext = {}) {
const bestCandidate = collectLogoCandidates(crawlSummary, brandContext)[0];
if (!bestCandidate || bestCandidate.score <= 0) return '';
if (
bestCandidate.sources.has('model')
|| bestCandidate.sources.has('branding')
|| LOGO_URL_POSITIVE_REGEX.test(bestCandidate.url)
|| LOGO_URL_ICON_REGEX.test(bestCandidate.url)
) {
return bestCandidate.url;
}
return '';
}
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)
: []; : [];
const logoUrl = normalizeText(source?.logoUrl);
const relevantImagePaths = Array.isArray(source?.relevantImagePaths) const relevantImagePaths = Array.isArray(source?.relevantImagePaths)
? source.relevantImagePaths.map((path) => normalizeText(path)).filter(Boolean) ? source.relevantImagePaths.map((path) => normalizeText(path)).filter(Boolean)
: []; : [];
return { return {
previewTagline: taglines[0] || '', previewTagline: taglines[0] || '',
previewImagePath: relevantImagePaths[0] || '', previewImagePath: logoUrl || relevantImagePaths[0] || '',
}; };
} }
function mergeBusinessSummary(baseBusiness = {}, context = null) { function mergeBusinessSummary(baseBusiness = {}, context = null, crawlSummary = null) {
const previewSummary = getBusinessPreviewSummary(context || baseBusiness); const logoUrl = firstNonEmptyText(
context?.logoUrl,
baseBusiness?.logoUrl,
selectCanonicalLogoSourceUrl(crawlSummary, context || baseBusiness)
);
const previewSummary = getBusinessPreviewSummary({
...(context || baseBusiness),
logoUrl,
});
const relevantImagePaths = normalizeUrlList( const relevantImagePaths = normalizeUrlList(
Array.isArray(baseBusiness?.relevantImagePaths) && baseBusiness.relevantImagePaths.length Array.isArray(baseBusiness?.relevantImagePaths) && baseBusiness.relevantImagePaths.length
? baseBusiness.relevantImagePaths ? baseBusiness.relevantImagePaths
@ -815,8 +937,9 @@ function mergeBusinessSummary(baseBusiness = {}, context = null) {
return { return {
...baseBusiness, ...baseBusiness,
logoUrl,
previewTagline: normalizeText(baseBusiness.previewTagline) || previewSummary.previewTagline, previewTagline: normalizeText(baseBusiness.previewTagline) || previewSummary.previewTagline,
previewImagePath: normalizeText(baseBusiness.previewImagePath) || previewSummary.previewImagePath, previewImagePath: logoUrl || normalizeText(baseBusiness.previewImagePath) || previewSummary.previewImagePath,
relevantImagePaths, relevantImagePaths,
}; };
} }
@ -897,7 +1020,12 @@ async function finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext) {
const existingBusiness = await findBusinessByApplicationId(merchantId, applicationId); const existingBusiness = await findBusinessByApplicationId(merchantId, applicationId);
if (existingBusiness) { if (existingBusiness) {
const existingContext = await fetchJSON(businessRoot(merchantId, existingBusiness.businessId), 'context').catch(() => null); const existingContext = await fetchJSON(businessRoot(merchantId, existingBusiness.businessId), 'context').catch(() => null);
const mergedBusiness = existingContext ? { ...existingContext } : mergeBusinessSummary(existingBusiness); const mergedBusiness = existingContext
? {
...existingContext,
...mergeBusinessSummary(existingBusiness, existingContext, crawlSummary),
}
: mergeBusinessSummary(existingBusiness, null, crawlSummary);
return { return {
business: { business: {
...mergedBusiness, ...mergedBusiness,
@ -913,13 +1041,29 @@ async function finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext) {
const bizRoot = businessRoot(merchantId, businessId); const bizRoot = businessRoot(merchantId, businessId);
const imagesFolder = `${bizRoot}/images`; const imagesFolder = `${bizRoot}/images`;
const imagePaths = []; const imagePaths = [];
const uploadedImageBySourceUrl = new Map();
const imageCandidates = normalizeUrlList(brandContext?.relevantImageUrls); const imageCandidates = normalizeUrlList(brandContext?.relevantImageUrls);
for (let i = 0; i < Math.min(imageCandidates.length, 6); i += 1) { for (let i = 0; i < Math.min(imageCandidates.length, 6); i += 1) {
const uploaded = await uploadImageFromUrl(imageCandidates[i], imagesFolder, `image_${i + 1}`); const uploaded = await uploadImageFromUrl(imageCandidates[i], imagesFolder, `image_${i + 1}`);
if (uploaded) imagePaths.push(uploaded); if (uploaded) {
imagePaths.push(uploaded);
uploadedImageBySourceUrl.set(imageCandidates[i], uploaded);
}
} }
const selectedLogoSourceUrl = selectCanonicalLogoSourceUrl(crawlSummary, brandContext);
const logoUploadUrl = selectedLogoSourceUrl
? (
uploadedImageBySourceUrl.get(selectedLogoSourceUrl)
|| await uploadImageFromUrl(selectedLogoSourceUrl, imagesFolder, 'logo')
)
: '';
const logoUrl = normalizeText(logoUploadUrl || selectedLogoSourceUrl);
const relevantImagePaths = normalizeUrlList(
logoUrl ? [logoUrl, ...imagePaths] : imagePaths
);
let domain = normalizeText(crawlSummary?.domain); let domain = normalizeText(crawlSummary?.domain);
if (!domain) { if (!domain) {
try { try {
@ -940,7 +1084,8 @@ async function finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext) {
tone: brandContext.tone || 'professional', tone: brandContext.tone || 'professional',
taglines: Array.isArray(brandContext.taglines) ? brandContext.taglines : [], taglines: Array.isArray(brandContext.taglines) ? brandContext.taglines : [],
colors: Array.isArray(brandContext.colors) ? brandContext.colors : [], colors: Array.isArray(brandContext.colors) ? brandContext.colors : [],
relevantImagePaths: imagePaths, logoUrl,
relevantImagePaths,
aboutSummary: normalizeText(brandContext.aboutSummary) || extractAboutSummary(crawlSummary), aboutSummary: normalizeText(brandContext.aboutSummary) || extractAboutSummary(crawlSummary),
websiteUrl, websiteUrl,
crawlStats: crawlSummary?.siteStats || {}, crawlStats: crawlSummary?.siteStats || {},
@ -959,6 +1104,7 @@ async function finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext) {
applicationId, applicationId,
brandName: contextJson.brandName, brandName: contextJson.brandName,
domain: contextJson.domain, domain: contextJson.domain,
logoUrl: contextJson.logoUrl,
previewTagline: previewSummary.previewTagline, previewTagline: previewSummary.previewTagline,
previewImagePath: previewSummary.previewImagePath, previewImagePath: previewSummary.previewImagePath,
relevantImagePaths: normalizeUrlList(contextJson.relevantImagePaths), relevantImagePaths: normalizeUrlList(contextJson.relevantImagePaths),
@ -970,7 +1116,7 @@ async function finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext) {
return { return {
business: { business: {
...contextJson, ...contextJson,
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, imagePaths), scrapeArtifacts: buildScrapeArtifacts(crawlSummary, relevantImagePaths),
}, },
reusedExistingBusiness: false, reusedExistingBusiness: false,
}; };
@ -2456,6 +2602,8 @@ function buildStoredProfileFromValidation(baseProfile = {}, validation = {}) {
} }
profile.updatedAt = now; profile.updatedAt = now;
profile.isAutoNamed = baseProfile.isAutoNamed === true
|| (!Object.prototype.hasOwnProperty.call(baseProfile, 'isAutoNamed') && !normalizeText(baseProfile.name));
profile.provider = normalizeProvider({ profile.provider = normalizeProvider({
...profile.provider, ...profile.provider,
authKey: '', authKey: '',
@ -2464,7 +2612,7 @@ function buildStoredProfileFromValidation(baseProfile = {}, validation = {}) {
profile.curlAnalysis = sanitizeStoredCurlAnalysis(profile); profile.curlAnalysis = sanitizeStoredCurlAnalysis(profile);
profile.rawCurl = getStoredCurlTemplate(profile); profile.rawCurl = getStoredCurlTemplate(profile);
profile.rawCurlTemplate = profile.rawCurl; profile.rawCurlTemplate = profile.rawCurl;
return profile; return syncAutomaticProfileName(profile);
} }
function collectProfileInputPatch(payload = {}) { function collectProfileInputPatch(payload = {}) {
@ -2518,7 +2666,7 @@ function applyProfileInputPatch(profile, patch = {}) {
}, profile.updatedAt); }, profile.updatedAt);
profile.profileInputValues = normalizeProfileInputValues(profile.profileInputValues); profile.profileInputValues = normalizeProfileInputValues(profile.profileInputValues);
profile.curlAnalysis = sanitizeStoredCurlAnalysis(profile); profile.curlAnalysis = sanitizeStoredCurlAnalysis(profile);
return profile; return syncAutomaticProfileName(profile);
} }
function validateProfileInputPatch(patch = {}) { function validateProfileInputPatch(patch = {}) {
@ -2554,9 +2702,14 @@ async function validateCurlAndExtractProvider(rawCurl) {
throw createHttpError(422, senderIdError); throw createHttpError(422, senderIdError);
} }
const normalizedProvider = normalizeProvider({
...provider,
...(resolvedSenderId ? { senderId: resolvedSenderId } : {}),
});
return { return {
...validation, ...validation,
provider, provider: normalizedProvider,
}; };
} catch (err) { } catch (err) {
if (err.status) throw err; if (err.status) throw err;
@ -2573,15 +2726,23 @@ router.get('/', async (req, res) => {
const businesses = await getIndex(merchantId); const businesses = await getIndex(merchantId);
const hydratedBusinesses = await Promise.all( const hydratedBusinesses = await Promise.all(
businesses.map(async (business) => { businesses.map(async (business) => {
const hasPreviewSummary = normalizeText(business.previewTagline) || normalizeText(business.previewImagePath); const hasPreviewSummary = normalizeText(business.previewTagline)
|| normalizeText(business.previewImagePath)
|| normalizeText(business.logoUrl);
const hasRelevantImagePaths = Array.isArray(business.relevantImagePaths) && business.relevantImagePaths.length > 0; const hasRelevantImagePaths = Array.isArray(business.relevantImagePaths) && business.relevantImagePaths.length > 0;
const hasLogoUrl = normalizeText(business.logoUrl);
if (hasPreviewSummary && hasRelevantImagePaths) { if (hasPreviewSummary && (hasRelevantImagePaths || hasLogoUrl)) {
return mergeBusinessSummary(business); return mergeBusinessSummary(business);
} }
const context = await fetchJSON(businessRoot(merchantId, business.businessId), 'context').catch(() => null); const root = businessRoot(merchantId, business.businessId);
return mergeBusinessSummary(business, context); const [context, crawlSummary] = await Promise.all([
fetchJSON(root, 'context').catch(() => null),
fetchJSON(root, 'crawl_summary').catch(() => null),
]);
return mergeBusinessSummary(business, context, crawlSummary);
}) })
); );
@ -2684,12 +2845,13 @@ router.get('/:businessId', async (req, res) => {
if (!context) return res.status(404).json({ error: 'Business not found' }); if (!context) return res.status(404).json({ error: 'Business not found' });
if (!crawlSummary) { if (!crawlSummary) {
return res.json(context); return res.json(mergeBusinessSummary(context));
} }
const business = mergeBusinessSummary(context, null, crawlSummary);
res.json({ res.json({
...context, ...business,
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, context.relevantImagePaths), scrapeArtifacts: buildScrapeArtifacts(crawlSummary, business.relevantImagePaths),
}); });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@ -2993,9 +3155,6 @@ router.get('/:businessId/global-sms/profiles/:profileId/delete-impact', async (r
router.post('/:businessId/global-sms/profiles', async (req, res) => { router.post('/:businessId/global-sms/profiles', async (req, res) => {
try { try {
const { name, rawCurl, setActive } = req.body; const { name, rawCurl, setActive } = req.body;
if (!normalizeText(name)) {
return res.status(400).json({ error: 'name is required' });
}
if (!normalizeText(rawCurl)) { if (!normalizeText(rawCurl)) {
return res.status(400).json({ error: 'rawCurl is required' }); return res.status(400).json({ error: 'rawCurl is required' });
} }
@ -3007,11 +3166,13 @@ router.post('/:businessId/global-sms/profiles', async (req, res) => {
const { profileData } = await getProfileState(bizRoot); const { profileData } = await getProfileState(bizRoot);
const now = new Date().toISOString(); const now = new Date().toISOString();
const normalizedCurl = normalizeText(rawCurl); const normalizedCurl = normalizeText(rawCurl);
const normalizedRequestedName = normalizeText(name);
const validation = await validateCurlAndExtractProvider(normalizedCurl); const validation = await validateCurlAndExtractProvider(normalizedCurl);
const newProfile = buildStoredProfileFromValidation({ const newProfile = buildStoredProfileFromValidation({
id: uuidv4(), id: uuidv4(),
name: normalizeText(name), ...(normalizedRequestedName ? { name: normalizedRequestedName } : {}),
isAutoNamed: !normalizedRequestedName,
isDefault: false, isDefault: false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -3053,7 +3214,10 @@ router.patch('/:businessId/global-sms/profiles/:profileId', async (req, res) =>
const profile = profileData.profiles.find(p => p.id === profileId); const profile = profileData.profiles.find(p => p.id === profileId);
if (!profile) return res.status(404).json({ error: 'Profile not found' }); if (!profile) return res.status(404).json({ error: 'Profile not found' });
if (name !== undefined) profile.name = normalizeText(name); if (name !== undefined) {
profile.name = normalizeText(name);
profile.isAutoNamed = false;
}
applyProfileInputPatch(profile, profilePatch); applyProfileInputPatch(profile, profilePatch);
await saveGlobalSmsProfiles(bizRoot, profileData); await saveGlobalSmsProfiles(bizRoot, profileData);

View File

@ -6,6 +6,38 @@ function normalizeList(value) {
return Array.isArray(value) ? value : []; return Array.isArray(value) ? value : [];
} }
function isAbsoluteHttpUrl(value) {
const normalized = normalizeText(value);
if (!normalized) return false;
try {
const parsed = new URL(normalized);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function collectImageUrls(value, bucket = []) {
if (Array.isArray(value)) {
value.forEach((entry) => collectImageUrls(entry, bucket));
return bucket;
}
if (typeof value === 'string') {
if (isAbsoluteHttpUrl(value)) bucket.push(value);
return bucket;
}
if (!value || typeof value !== 'object') {
return bucket;
}
const url = normalizeText(value.url || value.href || value.src || value.secure_url);
if (isAbsoluteHttpUrl(url)) bucket.push(url);
return bucket;
}
function uniqueStrings(values) { function uniqueStrings(values) {
const seen = new Set(); const seen = new Set();
return normalizeList(values) return normalizeList(values)
@ -76,6 +108,46 @@ function dedupeLinks(links) {
}); });
} }
function scoreLogoCandidate(url) {
const normalized = normalizeText(url).toLowerCase();
if (!normalized) return -100;
let score = 0;
if (/logo|brandmark|wordmark|logomark/.test(normalized)) score += 40;
if (/favicon|apple-touch-icon|android-chrome|mstile|site-icon|siteicon|icon/.test(normalized)) score += 25;
if (/header|navbar|nav/.test(normalized)) score += 8;
if (/hero|banner|carousel|slider|product|collection|catalog|lookbook/.test(normalized)) score -= 35;
if (/social|facebook|instagram|twitter|linkedin|youtube|pinterest|avatar|profile/.test(normalized)) score -= 25;
if (/sprite|tracking|pixel|placeholder/.test(normalized)) score -= 40;
return score;
}
function rankLogoCandidates(values) {
return uniqueStrings(values)
.map((url, index) => ({
index,
score: scoreLogoCandidate(url),
url,
}))
.sort((left, right) => right.score - left.score || left.index - right.index)
.map((entry) => entry.url);
}
function extractMetadataImageCandidates(metadata = {}) {
const candidates = [];
Object.entries(metadata || {}).forEach(([key, value]) => {
const normalizedKey = normalizeText(key).toLowerCase();
if (!/(image|logo|icon|favicon|thumbnail|apple)/.test(normalizedKey)) return;
collectImageUrls(value, candidates);
});
return rankLogoCandidates(candidates).filter((url) => scoreLogoCandidate(url) > 0);
}
function summarizePage(page, pageType) { function summarizePage(page, pageType) {
const metadata = page?.metadata && typeof page.metadata === 'object' ? page.metadata : {}; const metadata = page?.metadata && typeof page.metadata === 'object' ? page.metadata : {};
@ -101,7 +173,7 @@ function buildRepresentativeTextBlocks(homepage, aboutPage, productPages) {
})); }));
} }
function flattenBranding(homepage) { function flattenBranding(homepage, topImages = []) {
const branding = homepage?.branding && typeof homepage.branding === 'object' ? homepage.branding : {}; const branding = homepage?.branding && typeof homepage.branding === 'object' ? homepage.branding : {};
const colorEntries = []; const colorEntries = [];
const logos = []; const logos = [];
@ -142,12 +214,22 @@ function flattenBranding(homepage) {
const brandName = normalizeText(branding.brandName || branding.brand_name || branding.name); const brandName = normalizeText(branding.brandName || branding.brand_name || branding.name);
if (brandName) brandNames.push(brandName); if (brandName) brandNames.push(brandName);
const metadataImageCandidates = extractMetadataImageCandidates(homepage?.metadata || {});
const topLogoCandidates = rankLogoCandidates(topImages).filter((url) => scoreLogoCandidate(url) > 0);
const logoCandidates = uniqueStrings([
...logos,
...metadataImageCandidates,
...topLogoCandidates,
]);
return { return {
colors: uniqueStrings(colorEntries.map((entry) => entry.hex)), colors: uniqueStrings(colorEntries.map((entry) => entry.hex)),
labeledColors: colorEntries.filter((entry, index, values) => ( labeledColors: colorEntries.filter((entry, index, values) => (
values.findIndex((candidate) => candidate.name === entry.name && candidate.hex === entry.hex) === index values.findIndex((candidate) => candidate.name === entry.name && candidate.hex === entry.hex) === index
)), )),
logos: uniqueStrings(logos), logos: uniqueStrings(logos),
logoCandidates,
primaryLogoUrl: logoCandidates[0] || '',
brandNames: uniqueStrings(brandNames), brandNames: uniqueStrings(brandNames),
}; };
} }
@ -188,7 +270,7 @@ function buildCrawlSummary(data = {}, startUrlOverride = '') {
...normalizeList(aboutRaw?.images), ...normalizeList(aboutRaw?.images),
...productRawPages.flatMap((page) => normalizeList(page?.images)), ...productRawPages.flatMap((page) => normalizeList(page?.images)),
]).slice(0, 60); ]).slice(0, 60);
const branding = flattenBranding(homepageRaw); const branding = flattenBranding(homepageRaw, topImages);
return { return {
startUrl, startUrl,

View File

@ -1006,6 +1006,7 @@ async function parseBrandContext(scrapedData = {}) {
` "tone": "one of ${BRAND_CONTEXT_TONE_OPTIONS.join(', ')}",`, ` "tone": "one of ${BRAND_CONTEXT_TONE_OPTIONS.join(', ')}",`,
' "taglines": ["up to 3 strings"],', ' "taglines": ["up to 3 strings"],',
' "colors": ["hex colors only"],', ' "colors": ["hex colors only"],',
' "logoUrl": "absolute http(s) URL for the best company logo or empty string",',
' "relevantImageUrls": ["3-5 absolute http(s) image URLs only"],', ' "relevantImageUrls": ["3-5 absolute http(s) image URLs only"],',
' "aboutSummary": "2-4 concise customer-facing sentences"', ' "aboutSummary": "2-4 concise customer-facing sentences"',
'}', '}',
@ -1014,6 +1015,8 @@ async function parseBrandContext(scrapedData = {}) {
'- No markdown.', '- No markdown.',
'- No explanatory prose.', '- No explanatory prose.',
'- Do not copy the About page verbatim.', '- Do not copy the About page verbatim.',
'- `logoUrl` must be a real brand logo or wordmark when one is available. Prefer storefront/header logos from branding_json.logos.',
'- If no credible logo is present, return an empty string for `logoUrl`.',
'- Exclude icons, tracking pixels, and data URLs from images.', '- Exclude icons, tracking pixels, and data URLs from images.',
'', '',
`start_url: ${String(scrapedData.startUrl || '')}`, `start_url: ${String(scrapedData.startUrl || '')}`,
@ -1037,12 +1040,14 @@ async function parseBrandContext(scrapedData = {}) {
}); });
const normalizedTone = normalizeText(String(result.tone || '')).toLowerCase(); const normalizedTone = normalizeText(String(result.tone || '')).toLowerCase();
const normalizedLogoUrl = normalizeText(String(result.logoUrl || ''));
return { return {
brandName: normalizeText(String(result.brandName || '')) || 'Unknown Brand', brandName: normalizeText(String(result.brandName || '')) || 'Unknown Brand',
tone: BRAND_CONTEXT_TONE_OPTIONS.includes(normalizedTone) ? normalizedTone : 'professional', tone: BRAND_CONTEXT_TONE_OPTIONS.includes(normalizedTone) ? normalizedTone : 'professional',
taglines: sanitizeStringArray(result.taglines, { maxItems: 3 }), taglines: sanitizeStringArray(result.taglines, { maxItems: 3 }),
colors: sanitizeStringArray(result.colors), colors: sanitizeStringArray(result.colors),
logoUrl: isAbsoluteHttpUrl(normalizedLogoUrl) ? normalizedLogoUrl : '',
relevantImageUrls: sanitizeStringArray(result.relevantImageUrls, { maxItems: 5, allowUrlsOnly: true }), relevantImageUrls: sanitizeStringArray(result.relevantImageUrls, { maxItems: 5, allowUrlsOnly: true }),
aboutSummary: normalizeText(String(result.aboutSummary || '')), aboutSummary: normalizeText(String(result.aboutSummary || '')),
}; };