Setting provider name auto
This commit is contained in:
parent
6448ad1e32
commit
232d734c98
|
|
@ -1,5 +1,6 @@
|
|||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useBusiness } from '../context/BusinessContext';
|
||||
import { getBusinessImage } from '../utils/businessProfile';
|
||||
|
||||
const SVG_ICONS = {
|
||||
analytics: (
|
||||
|
|
@ -64,26 +65,6 @@ function StageMarker({ done, active, enabled }) {
|
|||
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 = '' }) {
|
||||
const {
|
||||
activeBusiness,
|
||||
|
|
@ -95,7 +76,7 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
|
|||
} = useBusiness();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const businessImage = getSidebarBusinessImage(activeBusiness);
|
||||
const businessImage = getBusinessImage(activeBusiness);
|
||||
|
||||
const analyticsPath = `/${activeBusinessId}/analytics`;
|
||||
const globalSmsPath = `/${activeBusinessId}/global-sms`;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import apiClient from '../api/client';
|
|||
import { useBusiness } from '../context/BusinessContext';
|
||||
|
||||
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
|
||||
const PENDING_SENDER_ID_PROFILE_NAME = 'Pending Sender ID';
|
||||
|
||||
function formatUpdatedAt(value) {
|
||||
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.';
|
||||
}
|
||||
|
||||
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 }) {
|
||||
if (!preview) return null;
|
||||
|
||||
|
|
@ -133,7 +140,6 @@ export default function GlobalSms() {
|
|||
const [savingInputs, setSavingInputs] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formCurl, setFormCurl] = useState('');
|
||||
const [formSetActive, setFormSetActive] = useState(true);
|
||||
const [inputForm, setInputForm] = useState({});
|
||||
|
|
@ -146,10 +152,12 @@ export default function GlobalSms() {
|
|||
() => profiles.find((profile) => profile.id === activeProfileId) || null,
|
||||
[profiles, activeProfileId],
|
||||
);
|
||||
const missingInputs = activeProfile?.executionReadiness?.missingProfileInputs || [];
|
||||
const missingInputs = useMemo(
|
||||
() => activeProfile?.executionReadiness?.missingProfileInputs || [],
|
||||
[activeProfile?.executionReadiness?.missingProfileInputs],
|
||||
);
|
||||
const hasProfiles = profiles.length > 0;
|
||||
const eventsPath = `/${businessId}/events`;
|
||||
const analyticsPath = `/${businessId}/analytics`;
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -198,7 +206,7 @@ export default function GlobalSms() {
|
|||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!formName.trim() || !formCurl.trim()) return;
|
||||
if (!formCurl.trim()) return;
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
|
@ -207,12 +215,10 @@ export default function GlobalSms() {
|
|||
|
||||
try {
|
||||
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles`, {
|
||||
name: formName.trim(),
|
||||
rawCurl: formCurl.trim(),
|
||||
setActive: formSetActive,
|
||||
});
|
||||
|
||||
setFormName('');
|
||||
setFormCurl('');
|
||||
setFormSetActive(true);
|
||||
setSuccess('Profile created successfully.');
|
||||
|
|
@ -387,7 +393,12 @@ export default function GlobalSms() {
|
|||
{activeProfile ? (
|
||||
<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">
|
||||
<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">
|
||||
{activeProfile.executionReadiness?.isSetupComplete ? 'Setup Complete' : 'Missing Information'}
|
||||
</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="min-w-0 flex-1">
|
||||
<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 && (
|
||||
<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
|
||||
|
|
@ -572,18 +583,6 @@ export default function GlobalSms() {
|
|||
)}
|
||||
|
||||
<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>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-text-primary">Provider cURL Command</label>
|
||||
<textarea
|
||||
|
|
@ -594,6 +593,9 @@ export default function GlobalSms() {
|
|||
required
|
||||
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>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ import apiClient from '../api/client';
|
|||
import { useBusiness } from '../context/BusinessContext';
|
||||
|
||||
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) {
|
||||
if (!value) return '';
|
||||
|
|
@ -448,7 +455,7 @@ export default function Providers() {
|
|||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ function firstNonEmptyText(...values) {
|
|||
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) {
|
||||
if (!domain) return '';
|
||||
if (typeof domain === 'string') return normalizeText(domain);
|
||||
|
|
@ -170,11 +180,14 @@ export function getBusinessTagline(entity) {
|
|||
|
||||
export function getBusinessImage(entity) {
|
||||
const relevantImage = normalizeList(entity?.relevantImagePaths)[0];
|
||||
if (relevantImage) return relevantImage;
|
||||
const scrapedLogo = getScrapedLogoUrl(entity);
|
||||
|
||||
return (
|
||||
entity?.imageUrl
|
||||
|| entity?.logoUrl
|
||||
entity?.logoUrl
|
||||
|| scrapedLogo
|
||||
|| entity?.imageUrl
|
||||
|| entity?.previewImagePath
|
||||
|| relevantImage
|
||||
|| entity?.brandImageUrl
|
||||
|| entity?.image
|
||||
|| ''
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ const RUNTIME_TOKEN_LABELS = {
|
|||
};
|
||||
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 PENDING_SENDER_ID_PROFILE_NAME = 'Pending Sender ID';
|
||||
|
||||
function createHttpError(status, message, extra = {}) {
|
||||
const err = new Error(message);
|
||||
|
|
@ -218,6 +219,23 @@ function validateSenderId(senderId) {
|
|||
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) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
|
||||
|
||||
|
|
@ -558,9 +576,10 @@ function getProfileDisplayValue(profile = {}, key) {
|
|||
}
|
||||
|
||||
function serializeProfile(profile = {}) {
|
||||
const hydratedProfile = hydrateProfile(profile);
|
||||
const hydratedProfile = syncAutomaticProfileName(hydrateProfile(profile));
|
||||
return {
|
||||
...hydratedProfile,
|
||||
isAutoNamed: hydratedProfile.isAutoNamed === true,
|
||||
rawCurl: undefined,
|
||||
rawCurlTemplate: undefined,
|
||||
profileInputValues: undefined,
|
||||
|
|
@ -607,7 +626,7 @@ function sanitizeStoredCurlAnalysis(profile = {}) {
|
|||
}
|
||||
|
||||
function persistableProfile(profile = {}) {
|
||||
const hydratedProfile = hydrateProfile(profile);
|
||||
const hydratedProfile = syncAutomaticProfileName(hydrateProfile(profile));
|
||||
const normalizedAuthKey = firstNonEmptyResolvedText(
|
||||
hydratedProfile.profileInputValues?.authKey,
|
||||
hydratedProfile.provider?.authKey,
|
||||
|
|
@ -616,6 +635,7 @@ function persistableProfile(profile = {}) {
|
|||
return {
|
||||
id: hydratedProfile.id,
|
||||
name: normalizeText(hydratedProfile.name),
|
||||
isAutoNamed: hydratedProfile.isAutoNamed === true,
|
||||
rawCurl: getStoredCurlTemplate(hydratedProfile),
|
||||
isDefault: hydratedProfile.isDefault === true,
|
||||
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 = {}) {
|
||||
const taglines = Array.isArray(source?.taglines)
|
||||
? source.taglines.map((tagline) => normalizeText(tagline)).filter(Boolean)
|
||||
: [];
|
||||
const logoUrl = normalizeText(source?.logoUrl);
|
||||
const relevantImagePaths = Array.isArray(source?.relevantImagePaths)
|
||||
? source.relevantImagePaths.map((path) => normalizeText(path)).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return {
|
||||
previewTagline: taglines[0] || '',
|
||||
previewImagePath: relevantImagePaths[0] || '',
|
||||
previewImagePath: logoUrl || relevantImagePaths[0] || '',
|
||||
};
|
||||
}
|
||||
|
||||
function mergeBusinessSummary(baseBusiness = {}, context = null) {
|
||||
const previewSummary = getBusinessPreviewSummary(context || baseBusiness);
|
||||
function mergeBusinessSummary(baseBusiness = {}, context = null, crawlSummary = null) {
|
||||
const logoUrl = firstNonEmptyText(
|
||||
context?.logoUrl,
|
||||
baseBusiness?.logoUrl,
|
||||
selectCanonicalLogoSourceUrl(crawlSummary, context || baseBusiness)
|
||||
);
|
||||
const previewSummary = getBusinessPreviewSummary({
|
||||
...(context || baseBusiness),
|
||||
logoUrl,
|
||||
});
|
||||
const relevantImagePaths = normalizeUrlList(
|
||||
Array.isArray(baseBusiness?.relevantImagePaths) && baseBusiness.relevantImagePaths.length
|
||||
? baseBusiness.relevantImagePaths
|
||||
|
|
@ -815,8 +937,9 @@ function mergeBusinessSummary(baseBusiness = {}, context = null) {
|
|||
|
||||
return {
|
||||
...baseBusiness,
|
||||
logoUrl,
|
||||
previewTagline: normalizeText(baseBusiness.previewTagline) || previewSummary.previewTagline,
|
||||
previewImagePath: normalizeText(baseBusiness.previewImagePath) || previewSummary.previewImagePath,
|
||||
previewImagePath: logoUrl || normalizeText(baseBusiness.previewImagePath) || previewSummary.previewImagePath,
|
||||
relevantImagePaths,
|
||||
};
|
||||
}
|
||||
|
|
@ -897,7 +1020,12 @@ async function finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext) {
|
|||
const existingBusiness = await findBusinessByApplicationId(merchantId, applicationId);
|
||||
if (existingBusiness) {
|
||||
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 {
|
||||
business: {
|
||||
...mergedBusiness,
|
||||
|
|
@ -913,13 +1041,29 @@ async function finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext) {
|
|||
const bizRoot = businessRoot(merchantId, businessId);
|
||||
const imagesFolder = `${bizRoot}/images`;
|
||||
const imagePaths = [];
|
||||
const uploadedImageBySourceUrl = new Map();
|
||||
const imageCandidates = normalizeUrlList(brandContext?.relevantImageUrls);
|
||||
|
||||
for (let i = 0; i < Math.min(imageCandidates.length, 6); 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);
|
||||
if (!domain) {
|
||||
try {
|
||||
|
|
@ -940,7 +1084,8 @@ async function finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext) {
|
|||
tone: brandContext.tone || 'professional',
|
||||
taglines: Array.isArray(brandContext.taglines) ? brandContext.taglines : [],
|
||||
colors: Array.isArray(brandContext.colors) ? brandContext.colors : [],
|
||||
relevantImagePaths: imagePaths,
|
||||
logoUrl,
|
||||
relevantImagePaths,
|
||||
aboutSummary: normalizeText(brandContext.aboutSummary) || extractAboutSummary(crawlSummary),
|
||||
websiteUrl,
|
||||
crawlStats: crawlSummary?.siteStats || {},
|
||||
|
|
@ -959,6 +1104,7 @@ async function finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext) {
|
|||
applicationId,
|
||||
brandName: contextJson.brandName,
|
||||
domain: contextJson.domain,
|
||||
logoUrl: contextJson.logoUrl,
|
||||
previewTagline: previewSummary.previewTagline,
|
||||
previewImagePath: previewSummary.previewImagePath,
|
||||
relevantImagePaths: normalizeUrlList(contextJson.relevantImagePaths),
|
||||
|
|
@ -970,7 +1116,7 @@ async function finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext) {
|
|||
return {
|
||||
business: {
|
||||
...contextJson,
|
||||
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, imagePaths),
|
||||
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, relevantImagePaths),
|
||||
},
|
||||
reusedExistingBusiness: false,
|
||||
};
|
||||
|
|
@ -2456,6 +2602,8 @@ function buildStoredProfileFromValidation(baseProfile = {}, validation = {}) {
|
|||
}
|
||||
|
||||
profile.updatedAt = now;
|
||||
profile.isAutoNamed = baseProfile.isAutoNamed === true
|
||||
|| (!Object.prototype.hasOwnProperty.call(baseProfile, 'isAutoNamed') && !normalizeText(baseProfile.name));
|
||||
profile.provider = normalizeProvider({
|
||||
...profile.provider,
|
||||
authKey: '',
|
||||
|
|
@ -2464,7 +2612,7 @@ function buildStoredProfileFromValidation(baseProfile = {}, validation = {}) {
|
|||
profile.curlAnalysis = sanitizeStoredCurlAnalysis(profile);
|
||||
profile.rawCurl = getStoredCurlTemplate(profile);
|
||||
profile.rawCurlTemplate = profile.rawCurl;
|
||||
return profile;
|
||||
return syncAutomaticProfileName(profile);
|
||||
}
|
||||
|
||||
function collectProfileInputPatch(payload = {}) {
|
||||
|
|
@ -2518,7 +2666,7 @@ function applyProfileInputPatch(profile, patch = {}) {
|
|||
}, profile.updatedAt);
|
||||
profile.profileInputValues = normalizeProfileInputValues(profile.profileInputValues);
|
||||
profile.curlAnalysis = sanitizeStoredCurlAnalysis(profile);
|
||||
return profile;
|
||||
return syncAutomaticProfileName(profile);
|
||||
}
|
||||
|
||||
function validateProfileInputPatch(patch = {}) {
|
||||
|
|
@ -2554,9 +2702,14 @@ async function validateCurlAndExtractProvider(rawCurl) {
|
|||
throw createHttpError(422, senderIdError);
|
||||
}
|
||||
|
||||
const normalizedProvider = normalizeProvider({
|
||||
...provider,
|
||||
...(resolvedSenderId ? { senderId: resolvedSenderId } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
...validation,
|
||||
provider,
|
||||
provider: normalizedProvider,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.status) throw err;
|
||||
|
|
@ -2573,15 +2726,23 @@ router.get('/', async (req, res) => {
|
|||
const businesses = await getIndex(merchantId);
|
||||
const hydratedBusinesses = await Promise.all(
|
||||
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 hasLogoUrl = normalizeText(business.logoUrl);
|
||||
|
||||
if (hasPreviewSummary && hasRelevantImagePaths) {
|
||||
if (hasPreviewSummary && (hasRelevantImagePaths || hasLogoUrl)) {
|
||||
return mergeBusinessSummary(business);
|
||||
}
|
||||
|
||||
const context = await fetchJSON(businessRoot(merchantId, business.businessId), 'context').catch(() => null);
|
||||
return mergeBusinessSummary(business, context);
|
||||
const root = businessRoot(merchantId, business.businessId);
|
||||
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 (!crawlSummary) {
|
||||
return res.json(context);
|
||||
return res.json(mergeBusinessSummary(context));
|
||||
}
|
||||
|
||||
const business = mergeBusinessSummary(context, null, crawlSummary);
|
||||
res.json({
|
||||
...context,
|
||||
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, context.relevantImagePaths),
|
||||
...business,
|
||||
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, business.relevantImagePaths),
|
||||
});
|
||||
} catch (err) {
|
||||
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) => {
|
||||
try {
|
||||
const { name, rawCurl, setActive } = req.body;
|
||||
if (!normalizeText(name)) {
|
||||
return res.status(400).json({ error: 'name is required' });
|
||||
}
|
||||
if (!normalizeText(rawCurl)) {
|
||||
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 now = new Date().toISOString();
|
||||
const normalizedCurl = normalizeText(rawCurl);
|
||||
const normalizedRequestedName = normalizeText(name);
|
||||
const validation = await validateCurlAndExtractProvider(normalizedCurl);
|
||||
|
||||
const newProfile = buildStoredProfileFromValidation({
|
||||
id: uuidv4(),
|
||||
name: normalizeText(name),
|
||||
...(normalizedRequestedName ? { name: normalizedRequestedName } : {}),
|
||||
isAutoNamed: !normalizedRequestedName,
|
||||
isDefault: false,
|
||||
createdAt: 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);
|
||||
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);
|
||||
|
||||
await saveGlobalSmsProfiles(bizRoot, profileData);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,38 @@ function normalizeList(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) {
|
||||
const seen = new Set();
|
||||
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) {
|
||||
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 colorEntries = [];
|
||||
const logos = [];
|
||||
|
|
@ -142,12 +214,22 @@ function flattenBranding(homepage) {
|
|||
const brandName = normalizeText(branding.brandName || branding.brand_name || branding.name);
|
||||
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 {
|
||||
colors: uniqueStrings(colorEntries.map((entry) => entry.hex)),
|
||||
labeledColors: colorEntries.filter((entry, index, values) => (
|
||||
values.findIndex((candidate) => candidate.name === entry.name && candidate.hex === entry.hex) === index
|
||||
)),
|
||||
logos: uniqueStrings(logos),
|
||||
logoCandidates,
|
||||
primaryLogoUrl: logoCandidates[0] || '',
|
||||
brandNames: uniqueStrings(brandNames),
|
||||
};
|
||||
}
|
||||
|
|
@ -188,7 +270,7 @@ function buildCrawlSummary(data = {}, startUrlOverride = '') {
|
|||
...normalizeList(aboutRaw?.images),
|
||||
...productRawPages.flatMap((page) => normalizeList(page?.images)),
|
||||
]).slice(0, 60);
|
||||
const branding = flattenBranding(homepageRaw);
|
||||
const branding = flattenBranding(homepageRaw, topImages);
|
||||
|
||||
return {
|
||||
startUrl,
|
||||
|
|
|
|||
|
|
@ -1006,6 +1006,7 @@ async function parseBrandContext(scrapedData = {}) {
|
|||
` "tone": "one of ${BRAND_CONTEXT_TONE_OPTIONS.join(', ')}",`,
|
||||
' "taglines": ["up to 3 strings"],',
|
||||
' "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"],',
|
||||
' "aboutSummary": "2-4 concise customer-facing sentences"',
|
||||
'}',
|
||||
|
|
@ -1014,6 +1015,8 @@ async function parseBrandContext(scrapedData = {}) {
|
|||
'- No markdown.',
|
||||
'- No explanatory prose.',
|
||||
'- 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.',
|
||||
'',
|
||||
`start_url: ${String(scrapedData.startUrl || '')}`,
|
||||
|
|
@ -1037,12 +1040,14 @@ async function parseBrandContext(scrapedData = {}) {
|
|||
});
|
||||
|
||||
const normalizedTone = normalizeText(String(result.tone || '')).toLowerCase();
|
||||
const normalizedLogoUrl = normalizeText(String(result.logoUrl || ''));
|
||||
|
||||
return {
|
||||
brandName: normalizeText(String(result.brandName || '')) || 'Unknown Brand',
|
||||
tone: BRAND_CONTEXT_TONE_OPTIONS.includes(normalizedTone) ? normalizedTone : 'professional',
|
||||
taglines: sanitizeStringArray(result.taglines, { maxItems: 3 }),
|
||||
colors: sanitizeStringArray(result.colors),
|
||||
logoUrl: isAbsoluteHttpUrl(normalizedLogoUrl) ? normalizedLogoUrl : '',
|
||||
relevantImageUrls: sanitizeStringArray(result.relevantImageUrls, { maxItems: 5, allowUrlsOnly: true }),
|
||||
aboutSummary: normalizeText(String(result.aboutSummary || '')),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user