Try a method of storing in boltic table

This commit is contained in:
Ritul Jadhav 2026-03-31 15:00:25 +05:30
parent 37792c1704
commit 0922dbc7c1
21 changed files with 937 additions and 471 deletions

View File

@ -16,6 +16,7 @@ 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

@ -18,14 +18,14 @@ function SubLayout({ children }) {
{hasGlobalSms && ( {hasGlobalSms && (
<Link <Link
to={`/${activeBusinessId}/settings`} to={`/${activeBusinessId}/settings`}
className="w-10 h-10 rounded-full hover:bg-refresh-hover text-gray-500 hover:text-primary-blue flex items-center justify-center transition-colors shadow-sm border border-border-soft" className="w-10 h-10 rounded-full hover:bg-white text-gray-500 hover:text-primary-blue flex items-center justify-center transition-colors border border-border-soft"
title="Settings" title="Settings"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</Link> </Link>
)} )}
</header> </header>
<div className="flex-1 p-8 overflow-auto"> <div className="flex-1 p-5 overflow-auto">
{children} {children}
</div> </div>
</main> </main>
@ -42,7 +42,7 @@ function BusinessGuard({ children, isGlobalSmsRoute }) {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-page-bg"> <div className="min-h-screen flex items-center justify-center bg-page-bg">
<div className="w-8 h-8 border-2 border-refresh-active border-t-primary-blue rounded-full animate-spin" /> <div className="w-8 h-8 border-2 border-gray-200 border-t-primary-blue rounded-full animate-spin" />
</div> </div>
); );
} }

View File

@ -24,7 +24,7 @@ export default function ImagePicker({ currentImage, onSelect }) {
} }
if (images.length === 0) { if (images.length === 0) {
return <div className="text-sm text-gray-500 italic bg-gray-50 border border-gray-100 px-4 py-3 rounded-lg">No images available for this brand.</div>; return <div className="text-sm text-gray-500 italic bg-white border border-gray-100 px-4 py-2 rounded-lg">No images available for this brand.</div>;
} }
return ( return (
@ -37,14 +37,14 @@ export default function ImagePicker({ currentImage, onSelect }) {
onClick={() => onSelect(img.url)} onClick={() => onSelect(img.url)}
className={`relative rounded-lg overflow-hidden border-2 aspect-video transition-all ${ className={`relative rounded-lg overflow-hidden border-2 aspect-video transition-all ${
isSelected isSelected
? 'border-primary-blue ring-2 ring-primary-blue ring-opacity-50 shadow-md' ? 'border-primary-blue -blue '
: 'border-transparent hover:border-gray-300 opacity-80 hover:opacity-100 shadow-sm' : 'border-transparent hover:border-gray-300 opacity-80 hover:opacity-100 '
}`} }`}
> >
<img src={img.url} alt={`brand-pic-${i}`} className="w-full h-full object-cover" /> <img src={img.url} alt={`brand-pic-${i}`} className="w-full h-full object-cover" />
<div className={`absolute inset-0 bg-primary-blue/20 transition-opacity ${isSelected ? 'opacity-100' : 'opacity-0'}`} /> <div className={`absolute inset-0 bg-primary-blue/20 transition-opacity ${isSelected ? 'opacity-100' : 'opacity-0'}`} />
{isSelected && ( {isSelected && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 bg-primary-blue rounded-full flex items-center justify-center shadow-sm border-2 border-white"> <div className="absolute top-1.5 right-1.5 w-5 h-5 bg-primary-blue rounded-full flex items-center justify-center border-2 border-white">
<svg className="w-3 h-3 text-white" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg> <svg className="w-3 h-3 text-white" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
</div> </div>
)} )}

View File

@ -26,16 +26,16 @@ export default function RegisterBusinessModal({ onClose }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
<div className="bg-white border border-gray-200 rounded-lg p-8 w-full max-w-md shadow-sm"> <div className="bg-white border border-gray-200 rounded-lg p-5 w-full max-w-md ">
{status === 'success' && ( {status === 'success' && (
<div className="text-center"> <div className="text-center">
<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-white 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">Business Added!</h2> <h2 className="text-xl font-bold text-gray-800 mb-2">Business Added!</h2>
<p className="text-gray-500 text-sm mb-6 font-medium">Your business has been registered successfully.</p> <p className="text-gray-500 text-sm mb-6 font-medium">Your business has been registered successfully.</p>
<button <button
onClick={onClose} onClick={onClose}
className="w-full py-2.5 rounded-lg bg-primary-blue hover:bg-primary-dark shadow-sm text-white font-medium transition" className="w-full py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-medium transition"
> >
Done Done
</button> </button>
@ -45,7 +45,7 @@ export default function RegisterBusinessModal({ onClose }) {
{(status === 'idle' || status === 'loading' || status === 'error') && ( {(status === 'idle' || status === 'loading' || status === 'error') && (
<> <>
<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-800 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">
Enter the storefront website URL and we&apos;ll scrape it to detect the brand and set up your business. Enter the storefront website URL and we&apos;ll scrape it to detect the brand and set up your business.
</p> </p>
@ -60,13 +60,13 @@ export default function RegisterBusinessModal({ onClose }) {
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
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-primary-blue focus:border-transparent transition disabled:opacity-50 text-sm shadow-sm" className="w-full px-4 py-2 rounded-lg bg-white border border-gray-300 text-gray-800 placeholder-gray-400 font-medium focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent transition disabled:opacity-50 text-sm "
required required
/> />
</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-white border border-gray-200 rounded-lg px-3 py-2">{error}</p>
)} )}
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
@ -74,14 +74,14 @@ export default function RegisterBusinessModal({ onClose }) {
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={status === 'loading'} disabled={status === 'loading'}
className="flex-[0.8] py-2.5 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition disabled:opacity-50" className="flex-[0.8] py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-white text-sm font-medium transition disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={status === 'loading' || !url.trim()} disabled={status === 'loading' || !url.trim()}
className="flex-[1.2] py-2.5 rounded-lg bg-primary-blue hover:bg-primary-dark 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 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-medium transition disabled:opacity-50 flex items-center justify-center gap-2"
> >
{status === 'loading' ? ( {status === 'loading' ? (
<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Analysing</> <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Analysing</>

View File

@ -22,7 +22,7 @@ const SVG_ICONS = {
function TopLevelStatus({ done, active }) { function TopLevelStatus({ done, active }) {
if (done) { if (done) {
return ( return (
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary-blue text-white shadow-sm"> <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary-blue text-white ">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg> </svg>
@ -31,7 +31,7 @@ function TopLevelStatus({ done, active }) {
} }
if (active) { if (active) {
return <span className="inline-block h-2.5 w-2.5 rounded-full bg-primary-blue shadow-sm" />; return <span className="inline-block h-2.5 w-2.5 rounded-full bg-primary-blue " />;
} }
return <span className="inline-block h-2.5 w-2.5 rounded-full bg-gray-200" />; return <span className="inline-block h-2.5 w-2.5 rounded-full bg-gray-200" />;
@ -40,7 +40,7 @@ function TopLevelStatus({ done, active }) {
function StageMarker({ done, active, enabled }) { function StageMarker({ done, active, enabled }) {
if (done) { if (done) {
return ( return (
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-primary-blue text-white shadow-sm"> <span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-primary-blue text-white ">
<svg className="h-2.5 w-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-2.5 w-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg> </svg>
@ -49,7 +49,7 @@ function StageMarker({ done, active, enabled }) {
} }
if (active) { if (active) {
return <span className="inline-block h-3 w-3 rounded-full border-2 border-white bg-primary-blue shadow-[0_0_0_1px_var(--color-primary-blue)]" />; return <span className="inline-block h-3 w-3 rounded-full border-2 border-white bg-primary-blue -[0_0_0_1px_var(--color-primary-blue)]" />;
} }
if (!enabled) { if (!enabled) {
@ -126,18 +126,18 @@ export default function Sidebar() {
<div className="px-5 py-5 border-b border-gray-100"> <div className="px-5 py-5 border-b border-gray-100">
<button <button
onClick={handleSwitch} onClick={handleSwitch}
className="flex items-center gap-2 text-gray-500 hover:text-gray-900 transition text-sm group font-medium" className="flex items-center gap-2 text-gray-500 hover:text-gray-800 transition text-sm group font-medium"
> >
<span className="group-hover:-translate-x-0.5 transition-transform text-lg leading-none">&larr;</span> <span className="group-hover:-translate-x-0.5 transition-transform text-lg leading-none">&larr;</span>
<span>Switch Business</span> <span>Switch Business</span>
</button> </button>
{activeBusiness && ( {activeBusiness && (
<div className="mt-4 flex items-center gap-3"> <div className="mt-4 flex items-center gap-3">
<div className="w-8 h-8 rounded-md bg-primary-blue flex items-center justify-center text-sm font-bold text-white shrink-0 shadow-sm"> <div className="w-8 h-8 rounded-md bg-primary-blue flex items-center justify-center text-sm font-bold text-white shrink-0 ">
{activeBusiness.brandName?.[0]?.toUpperCase() || 'B'} {activeBusiness.brandName?.[0]?.toUpperCase() || 'B'}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">{activeBusiness.brandName}</p> <p className="text-sm font-semibold text-gray-800 truncate">{activeBusiness.brandName}</p>
<p className="text-xs text-gray-400 truncate font-medium">{activeBusiness.domain}</p> <p className="text-xs text-gray-400 truncate font-medium">{activeBusiness.domain}</p>
</div> </div>
</div> </div>
@ -161,10 +161,10 @@ export default function Sidebar() {
{item.enabled ? ( {item.enabled ? (
<NavLink <NavLink
to={item.to} to={item.to}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150 ${ className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors duration-150 ${
item.active item.active
? 'bg-gray-100/70 text-gray-900' ? 'bg-gray-100/70 text-gray-800'
: 'text-gray-500 hover:text-gray-900 hover:bg-gray-50' : 'text-gray-500 hover:text-gray-800 hover:bg-white'
}`} }`}
> >
{SVG_ICONS[item.id]} {SVG_ICONS[item.id]}
@ -186,7 +186,7 @@ export default function Sidebar() {
) : ( ) : (
<div <div
aria-disabled="true" aria-disabled="true"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 cursor-not-allowed select-none" className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-gray-300 cursor-not-allowed select-none"
> >
{SVG_ICONS[item.id]} {SVG_ICONS[item.id]}
<span className="flex-1 truncate">{item.label}</span> <span className="flex-1 truncate">{item.label}</span>
@ -203,13 +203,13 @@ export default function Sidebar() {
{item.substeps.map((substep) => ( {item.substeps.map((substep) => (
<div key={substep.id} className="relative flex items-center pr-3 group cursor-default"> <div key={substep.id} className="relative flex items-center pr-3 group cursor-default">
<div className="w-[44px] flex justify-center items-center shrink-0"> <div className="w-[44px] flex justify-center items-center shrink-0">
{substep.active && <div className="w-1.5 h-1.5 rounded-full bg-primary-blue z-10 shadow-[0_0_0_2px_white]" />} {substep.active && <div className="w-1.5 h-1.5 rounded-full bg-primary-blue z-10 -[0_0_0_2px_white]" />}
</div> </div>
<div <div
className={`flex-1 px-3 py-2.5 rounded-md text-[14px] transition-colors ${ className={`flex-1 px-3 py-2 rounded-md text-[14px] transition-colors ${
substep.active substep.active
? 'bg-refresh-hover text-primary-blue font-semibold' ? 'bg-white text-primary-blue font-semibold'
: 'text-gray-500 font-medium hover:text-gray-900 hover:bg-gray-50' : 'text-gray-500 font-medium hover:text-gray-800 hover:bg-white'
}`} }`}
> >
{substep.label} {substep.label}

View File

@ -28,11 +28,11 @@ export default function TestSmsModal({ businessId, template, onClose }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
<div className="bg-white border border-gray-200 rounded-lg p-8 w-full max-w-md shadow-sm"> <div className="bg-white border border-gray-200 rounded-lg p-5 w-full max-w-md ">
<div className="w-12 h-12 rounded-full bg-green-50 flex items-center justify-center mx-auto mb-4"> <div className="w-12 h-12 rounded-full bg-white flex items-center justify-center mx-auto mb-4">
<span className="text-xl">📱</span> <span className="text-xl">📱</span>
</div> </div>
<h3 className="text-lg font-bold text-gray-900 text-center mb-1">Test SMS</h3> <h3 className="text-lg font-bold text-gray-800 text-center mb-1">Test SMS</h3>
<p className="text-sm text-gray-500 text-center mb-6"> <p className="text-sm text-gray-500 text-center mb-6">
Enter a phone number to send a real test SMS for <span className="font-semibold text-gray-800 capitalize">{template.eventLabel || template.eventSlug.replace(/_/g, ' ')}</span> Enter a phone number to send a real test SMS for <span className="font-semibold text-gray-800 capitalize">{template.eventLabel || template.eventSlug.replace(/_/g, ' ')}</span>
</p> </p>
@ -40,7 +40,7 @@ export default function TestSmsModal({ businessId, template, onClose }) {
{!result ? ( {!result ? (
<form onSubmit={handleSend} className="space-y-4"> <form onSubmit={handleSend} className="space-y-4">
{error && ( {error && (
<div className="px-4 py-2.5 rounded-md bg-red-50 border border-red-200 text-red-700 text-sm font-medium"> <div className="px-4 py-2 rounded-md bg-white border border-gray-200 text-gray-700 text-sm font-medium">
{error} {error}
</div> </div>
)} )}
@ -52,7 +52,7 @@ export default function TestSmsModal({ businessId, template, onClose }) {
value={toNumber} value={toNumber}
onChange={e => setToNumber(e.target.value)} onChange={e => setToNumber(e.target.value)}
placeholder="e.g. 919876543210 (with country code)" placeholder="e.g. 919876543210 (with country code)"
className="w-full px-4 py-2.5 rounded-lg bg-gray-50 border border-gray-300 font-mono text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm" className="w-full px-4 py-2 rounded-lg bg-white border border-gray-300 font-mono text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
autoFocus autoFocus
required required
/> />
@ -64,14 +64,14 @@ export default function TestSmsModal({ businessId, template, onClose }) {
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={sending} disabled={sending}
className="flex-1 py-2.5 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition disabled:opacity-50" className="flex-1 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-white text-sm font-medium transition disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={sending || !toNumber.trim()} disabled={sending || !toNumber.trim()}
className="flex-1 py-2.5 rounded-lg bg-green-600 hover:bg-green-700 text-white text-sm font-semibold transition shadow-sm disabled:opacity-50 flex items-center justify-center gap-2" className="flex-1 py-2 rounded-lg bg-green-600 hover:bg-green-700 text-white text-sm font-semibold transition disabled:opacity-50 flex items-center justify-center gap-2"
> >
{sending ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Sending</> : 'Send Test SMS'} {sending ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Sending</> : 'Send Test SMS'}
</button> </button>
@ -79,21 +79,21 @@ export default function TestSmsModal({ businessId, template, onClose }) {
</form> </form>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className={`px-4 py-3 rounded-lg border text-sm font-medium ${result.success ? 'bg-green-50 border-green-200 text-green-800' : 'bg-red-50 border-red-200 text-red-800'}`}> <div className={`px-4 py-2 rounded-lg border text-sm font-medium ${result.success ? 'bg-white border-gray-200 text-green-800' : 'bg-white border-gray-200 text-red-800'}`}>
{result.success ? '✓ SMS sent successfully!' : '✗ SMS send failed'} {result.success ? '✓ SMS sent successfully!' : '✗ SMS send failed'}
{result.statusCode && <span className="ml-2 opacity-60">HTTP {result.statusCode}</span>} {result.statusCode && <span className="ml-2 opacity-60">HTTP {result.statusCode}</span>}
</div> </div>
{result.response && ( {result.response && (
<div> <div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Provider Response</label> <label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Provider Response</label>
<pre className="p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs font-mono text-gray-700 overflow-x-auto whitespace-pre-wrap break-all"> <pre className="p-3 bg-white border border-gray-200 rounded-lg text-xs font-mono text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">
{typeof result.response === 'string' ? result.response : JSON.stringify(result.response, null, 2)} {typeof result.response === 'string' ? result.response : JSON.stringify(result.response, null, 2)}
</pre> </pre>
</div> </div>
)} )}
<button <button
onClick={onClose} onClick={onClose}
className="w-full py-2.5 rounded-lg bg-gray-900 hover:bg-gray-800 text-white text-sm font-semibold transition" className="w-full py-2 rounded-lg bg-gray-900 hover:bg-gray-800 text-white text-sm font-semibold transition"
> >
Close Close
</button> </button>

View File

@ -104,8 +104,8 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm overflow-y-auto pt-10 pb-10"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm overflow-y-auto pt-10 pb-10">
<div className="bg-surface-white border border-border-main rounded-lg p-8 w-full max-w-md shadow-sm my-auto"> <div className="bg-surface-white border border-border-main rounded-lg p-5 w-full max-w-md my-auto">
<div className="w-12 h-12 rounded-full bg-tags-bg border border-tags-border flex items-center justify-center mx-auto mb-4"> <div className="w-12 h-12 rounded-full bg-white border border-gray-200 flex items-center justify-center mx-auto mb-4">
<span className="text-xl"></span> <span className="text-xl"></span>
</div> </div>
@ -127,7 +127,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
)} )}
{error && ( {error && (
<div className="mb-4 px-4 py-2.5 rounded-md text-error-text bg-delayed-bg border border-delayed-border text-sm font-medium"> <div className="mb-4 px-4 py-2 rounded-md text-error-text bg-white border border-gray-200 text-sm font-medium">
{error} {error}
</div> </div>
)} )}
@ -141,7 +141,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
type="text" type="text"
value={providerForm.providerName} value={providerForm.providerName}
onChange={e => setProviderForm(prev => ({ ...prev, providerName: e.target.value }))} onChange={e => setProviderForm(prev => ({ ...prev, providerName: e.target.value }))}
className="w-full px-4 py-2.5 rounded-lg bg-page-bg border border-border-main text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm" className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
placeholder="e.g. MSG91" placeholder="e.g. MSG91"
autoFocus autoFocus
required required
@ -156,7 +156,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
type="text" type="text"
value={providerForm.senderId} value={providerForm.senderId}
onChange={e => setProviderForm(prev => ({ ...prev, senderId: e.target.value.toUpperCase() }))} onChange={e => setProviderForm(prev => ({ ...prev, senderId: e.target.value.toUpperCase() }))}
className="w-full px-4 py-2.5 rounded-lg bg-page-bg border border-border-main font-mono uppercase text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm" className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono uppercase text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
placeholder="6 CHARS" placeholder="6 CHARS"
maxLength={6} maxLength={6}
required required
@ -171,7 +171,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
type="text" type="text"
value={providerForm.dltEntityId} value={providerForm.dltEntityId}
onChange={e => setProviderForm(prev => ({ ...prev, dltEntityId: e.target.value }))} onChange={e => setProviderForm(prev => ({ ...prev, dltEntityId: e.target.value }))}
className="w-full px-4 py-2.5 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm" className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
placeholder="19-digit DLT PE ID" placeholder="19-digit DLT PE ID"
required required
/> />
@ -183,7 +183,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={savingProvider} disabled={savingProvider}
className="flex-1 py-2.5 rounded-lg border border-border-main text-text-primary hover:bg-page-bg text-sm font-medium transition disabled:opacity-50" className="flex-1 py-2 rounded-lg border border-border-main text-text-primary hover:bg-page-bg text-sm font-medium transition disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
@ -195,7 +195,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
if (field === 'dltEntityId') return !providerForm.dltEntityId.trim(); if (field === 'dltEntityId') return !providerForm.dltEntityId.trim();
return false; return false;
})} })}
className="flex-1 py-2.5 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition shadow-sm disabled:opacity-50 flex items-center justify-center gap-2" className="flex-1 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition disabled:opacity-50 flex items-center justify-center gap-2"
> >
{savingProvider ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving</> : 'Save Details'} {savingProvider ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving</> : 'Save Details'}
</button> </button>
@ -210,7 +210,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
value={templateId} value={templateId}
onChange={e => setTemplateId(e.target.value)} onChange={e => setTemplateId(e.target.value)}
placeholder="e.g. 1234567890987654321" placeholder="e.g. 1234567890987654321"
className="w-full px-4 py-2.5 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm" className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
autoFocus autoFocus
required required
/> />
@ -223,7 +223,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
value={toNumber} value={toNumber}
onChange={e => setToNumber(e.target.value)} onChange={e => setToNumber(e.target.value)}
placeholder="e.g. 919876543210" placeholder="e.g. 919876543210"
className="w-full px-4 py-2.5 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm" className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
required required
/> />
<p className="text-xs text-text-muted mt-1">This sends the publish-triggering SMS request.</p> <p className="text-xs text-text-muted mt-1">This sends the publish-triggering SMS request.</p>
@ -234,14 +234,14 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={publishing} disabled={publishing}
className="flex-1 py-2.5 rounded-lg border border-border-main text-text-primary hover:bg-page-bg text-sm font-medium transition disabled:opacity-50" className="flex-1 py-2 rounded-lg border border-border-main text-text-primary hover:bg-page-bg text-sm font-medium transition disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={publishing || !templateId.trim() || !toNumber.trim()} disabled={publishing || !templateId.trim() || !toNumber.trim()}
className="flex-1 py-2.5 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition shadow-sm disabled:opacity-50 flex items-center justify-center gap-2" className="flex-1 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition disabled:opacity-50 flex items-center justify-center gap-2"
> >
{publishing ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Publishing</> : 'Publish'} {publishing ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Publishing</> : 'Publish'}
</button> </button>

View File

@ -13,26 +13,26 @@ const NAV_CARDS = [
function DeleteConfirmModal({ brandName, onCancel, onConfirm, deleting }) { function DeleteConfirmModal({ brandName, onCancel, onConfirm, deleting }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
<div className="bg-white border border-gray-200 rounded-lg p-8 w-full max-w-md shadow-sm"> <div className="bg-white border border-gray-200 rounded-lg p-5 w-full max-w-md ">
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mx-auto mb-4"> <div className="w-12 h-12 rounded-full bg-white flex items-center justify-center mx-auto mb-4">
<span className="text-xl">🗑</span> <span className="text-xl">🗑</span>
</div> </div>
<h3 className="text-lg font-semibold text-gray-900 text-center mb-2">Delete Brand?</h3> <h3 className="text-lg font-semibold text-gray-800 text-center mb-2">Delete Brand?</h3>
<p className="text-sm text-gray-500 text-center mb-6"> <p className="text-sm text-gray-500 text-center mb-6">
This will permanently delete <span className="text-gray-900 font-medium">{brandName}</span> and all associated events, templates, and images. This cannot be undone. This will permanently delete <span className="text-gray-800 font-medium">{brandName}</span> and all associated events, templates, and images. This cannot be undone.
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={onCancel} onClick={onCancel}
disabled={deleting} disabled={deleting}
className="flex-1 py-2.5 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition disabled:opacity-50" className="flex-1 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-white text-sm font-medium transition disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="flex-1 py-2.5 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition shadow-sm disabled:opacity-50 flex items-center justify-center gap-2" className="flex-1 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition disabled:opacity-50 flex items-center justify-center gap-2"
> >
{deleting ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Deleting</> : 'Yes, Delete'} {deleting ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Deleting</> : 'Yes, Delete'}
</button> </button>
@ -64,8 +64,8 @@ export default function Brand() {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="min-h-screen flex items-center justify-center bg-white">
<div className="w-8 h-8 border-2 border-refresh-active border-t-indigo-600 rounded-full animate-spin" /> <div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
</div> </div>
); );
} }
@ -73,18 +73,18 @@ export default function Brand() {
// WELCOME SCREEN // WELCOME SCREEN
if (!brand) { if (!brand) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="min-h-screen flex items-center justify-center bg-white">
<div className="text-center max-w-lg px-8"> <div className="text-center max-w-lg px-8">
<div className="w-16 h-16 rounded-lg bg-primary-blue flex items-center justify-center mx-auto mb-6 text-2xl font-bold text-white shadow-sm"> <div className="w-16 h-16 rounded-lg bg-primary-blue flex items-center justify-center mx-auto mb-6 text-2xl font-bold text-white ">
S S
</div> </div>
<h1 className="text-3xl font-bold text-gray-900 mb-3 tracking-tight">SMS Template Extension</h1> <h1 className="text-3xl font-bold text-gray-800 mb-3 tracking-tight">SMS Template Extension</h1>
<p className="text-gray-500 text-base mb-8 leading-relaxed"> <p className="text-gray-500 text-base mb-8 leading-relaxed">
Generate TRAI-compliant SMS templates for your Fynd store. We'll scrape your website and use AI to extract your brand context automatically. Generate TRAI-compliant SMS templates for your Fynd store. We'll scrape your website and use AI to extract your brand context automatically.
</p> </p>
<button <button
onClick={() => setShowModal(true)} onClick={() => setShowModal(true)}
className="px-8 py-3 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-medium shadow-sm transition-all focus:ring-2 focus:ring-offset-2 focus:ring-primary-blue" className="px-8 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-medium transition-all focus:ring-2 focus:ring-offset-2 focus:ring-primary-blue"
> >
Register Your Business Register Your Business
</button> </button>
@ -96,17 +96,17 @@ export default function Brand() {
// BRAND DETAIL PAGE // BRAND DETAIL PAGE
return ( return (
<div className="min-h-screen bg-gray-50 p-8"> <div className="min-h-screen bg-white p-5">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* Brand header card */} {/* Brand header card */}
<div className="rounded-lg bg-white border border-gray-200 shadow-sm p-8 mb-6"> <div className="rounded-lg bg-white border border-gray-200 p-5 mb-6">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h1 className="text-3xl font-bold text-gray-900 tracking-tight truncate">{brand.brandName}</h1> <h1 className="text-3xl font-bold text-gray-800 tracking-tight truncate">{brand.brandName}</h1>
<p className="text-gray-500 mt-1 text-sm font-medium">{brand.domain}</p> <p className="text-gray-500 mt-1 text-sm font-medium">{brand.domain}</p>
<div className="flex items-center gap-2 mt-4 flex-wrap"> <div className="flex items-center gap-2 mt-4 flex-wrap">
<span className="text-xs px-2.5 py-1 rounded-md bg-refresh-hover border border-refresh-active text-primary-dark font-medium capitalize"> <span className="text-xs px-2.5 py-1 rounded-md bg-white border border-gray-200 text-primary-dark font-medium capitalize">
{brand.tone} {brand.tone}
</span> </span>
<span className="text-xs text-gray-400 font-medium tracking-wide"> <span className="text-xs text-gray-400 font-medium tracking-wide">
@ -116,7 +116,7 @@ export default function Brand() {
</div> </div>
<button <button
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
className="shrink-0 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 border border-red-200 px-3 py-2 rounded-lg font-medium transition" className="shrink-0 text-xs text-red-600 hover:text-gray-700 hover:bg-white border border-gray-200 px-3 py-2 rounded-lg font-medium transition"
> >
Delete Brand Delete Brand
</button> </button>
@ -142,7 +142,7 @@ export default function Brand() {
{brand.colors.map((c, i) => ( {brand.colors.map((c, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<div <div
className="w-6 h-6 rounded-md shadow-sm border border-gray-200" className="w-6 h-6 rounded-md border border-gray-200"
style={{ backgroundColor: c }} style={{ backgroundColor: c }}
title={c} title={c}
/> />
@ -156,11 +156,11 @@ export default function Brand() {
{/* Brand images */} {/* Brand images */}
{brand.relevantImagePaths?.length > 0 && ( {brand.relevantImagePaths?.length > 0 && (
<div className="rounded-lg bg-white border border-gray-200 shadow-sm p-6 mb-6"> <div className="rounded-lg bg-white border border-gray-200 p-5 mb-6">
<p className="text-xs text-gray-400 font-semibold uppercase tracking-wider mb-4">Brand Images</p> <p className="text-xs text-gray-400 font-semibold uppercase tracking-wider mb-4">Brand Images</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{brand.relevantImagePaths.map((url, i) => ( {brand.relevantImagePaths.map((url, i) => (
<div key={i} className="group relative rounded-lg overflow-hidden border border-gray-200 aspect-video bg-gray-50"> <div key={i} className="group relative rounded-lg overflow-hidden border border-gray-200 aspect-video bg-white">
<img <img
src={url} src={url}
alt={`brand image ${i + 1}`} alt={`brand image ${i + 1}`}
@ -182,10 +182,10 @@ export default function Brand() {
<Link <Link
key={card.to} key={card.to}
to={card.to} to={card.to}
className="rounded-lg bg-white border border-gray-200 shadow-sm p-5 hover:border-primary-blue hover:ring-1 hover:ring-primary-blue transition-all group" className="rounded-lg bg-white border border-gray-200 p-5 hover:border-primary-blue hover: hover:-blue transition-all group"
> >
<div className="text-2xl mb-3 grayscale group-hover:grayscale-0 opacity-80 group-hover:opacity-100 transition-all">{card.icon}</div> <div className="text-2xl mb-3 grayscale group-hover:grayscale-0 opacity-80 group-hover:opacity-100 transition-all">{card.icon}</div>
<p className="font-semibold text-gray-900 mb-1">{card.label}</p> <p className="font-semibold text-gray-800 mb-1">{card.label}</p>
<p className="text-sm text-gray-500 leading-relaxed">{card.desc}</p> <p className="text-sm text-gray-500 leading-relaxed">{card.desc}</p>
</Link> </Link>
))} ))}

View File

@ -5,36 +5,36 @@ import { useBusiness } from '../context/BusinessContext';
import RegisterBusinessModal from '../components/RegisterBusinessModal'; import RegisterBusinessModal from '../components/RegisterBusinessModal';
import { fetchActiveSalesChannels } from '../utils/fyndSalesChannels'; import { fetchActiveSalesChannels } from '../utils/fyndSalesChannels';
import { import {
getApplicationId,
getBusinessDomain, getBusinessDomain,
getBusinessImage, getBusinessImage,
getBusinessName, getBusinessName,
getBusinessTagline, getBusinessTagline,
getChannelId,
} from '../utils/businessProfile'; } from '../utils/businessProfile';
function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) { function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
<div className="bg-white border border-gray-200 rounded-lg p-8 w-full max-w-md shadow-sm"> <div className="bg-white border border-gray-200 rounded-lg p-5 w-full max-w-md ">
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mx-auto mb-4"> <div className="w-12 h-12 rounded-full bg-white flex items-center justify-center mx-auto mb-4">
<span className="text-xl">🗑</span> <span className="text-xl">🗑</span>
</div> </div>
<h3 className="text-lg font-semibold text-gray-900 text-center mb-2">Delete Business?</h3> <h3 className="text-lg font-semibold text-gray-800 text-center mb-2">Delete Business?</h3>
<p className="text-sm text-gray-500 text-center mb-6"> <p className="text-sm text-gray-500 text-center mb-6">
This will permanently delete <span className="text-gray-900 font-medium">{businessName}</span> and all its events, templates, and images. This cannot be undone. This will permanently delete <span className="text-gray-800 font-medium">{businessName}</span> and all its events, templates, and images. This cannot be undone.
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={onCancel} onClick={onCancel}
disabled={deleting} disabled={deleting}
className="flex-1 py-2.5 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition disabled:opacity-50" className="flex-1 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-white text-sm font-medium transition disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="flex-1 py-2.5 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition shadow-sm disabled:opacity-50 flex items-center justify-center gap-2" className="flex-1 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition disabled:opacity-50 flex items-center justify-center gap-2"
> >
{deleting ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Deleting</> : 'Yes, Delete'} {deleting ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Deleting</> : 'Yes, Delete'}
</button> </button>
@ -52,13 +52,13 @@ function BusinessCreatedModal({ business, onClose }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
<div className="bg-white border border-gray-200 rounded-lg p-8 w-full max-w-md shadow-sm"> <div className="bg-white border border-gray-200 rounded-lg p-5 w-full max-w-md ">
<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-white 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-800 mb-2 text-center">Business Added!</h2>
<p className="text-gray-500 text-sm mb-4 font-medium text-center">Your business is ready for onboarding.</p> <p className="text-gray-500 text-sm mb-4 font-medium text-center">Your business is ready for onboarding.</p>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 mb-6"> <div className="rounded-lg border border-gray-200 bg-white 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-lg overflow-hidden bg-white border border-gray-200 shadow-sm shrink-0 flex items-center justify-center"> <div className="w-16 h-16 rounded-lg overflow-hidden bg-white border border-gray-200 shrink-0 flex items-center justify-center">
{image ? ( {image ? (
<img src={image} alt={name} className="w-full h-full object-cover" /> <img src={image} alt={name} className="w-full h-full object-cover" />
) : ( ) : (
@ -74,7 +74,7 @@ function BusinessCreatedModal({ business, onClose }) {
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="w-full py-2.5 rounded-lg bg-primary-blue hover:bg-primary-dark shadow-sm text-white font-medium transition" className="w-full py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-medium transition"
> >
Done Done
</button> </button>
@ -83,37 +83,125 @@ function BusinessCreatedModal({ business, onClose }) {
); );
} }
function SalesChannelCard({ channel, disabled, onImport }) { function StatusBadge({ status }) {
const name = getBusinessName(channel); const isScraped = status === 'scraped';
const domain = getBusinessDomain(channel);
const image = getBusinessImage(channel);
return ( return (
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition hover:border-primary-blue hover:shadow-md"> <span
<div className="flex items-start gap-3"> className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${
<div className="w-12 h-12 rounded-lg overflow-hidden bg-gray-50 border border-gray-200 shrink-0 flex items-center justify-center"> isScraped
? 'bg-green-100 text-green-700'
: 'bg-amber-100 text-amber-700'
}`}
>
<span
className={`h-2 w-2 rounded-full ${
isScraped ? 'bg-green-500' : 'bg-amber-500'
}`}
/>
{isScraped ? 'Scraped' : 'Not Scraped Yet'}
</span>
);
}
function UnifiedBusinessCard({
item,
selectingBusinessId,
creatingSalesChannelId,
onSelect,
onImport,
onDelete,
onFallback,
}) {
const entity = item.business || item.channel;
const businessId = item.business?.businessId || '';
const channelId = getApplicationId(item.channel);
const image = getBusinessImage(entity);
const name = getBusinessName(entity);
const domain = getBusinessDomain(entity);
const tagline = getBusinessTagline(entity);
const isScraped = item.status === 'scraped';
const isOpening = isScraped && selectingBusinessId === businessId;
const isImporting = !isScraped && creatingSalesChannelId === channelId;
const hasWebsiteUrl = Boolean(item.channel?.websiteUrl);
return (
<div className="group rounded-lg bg-white border border-gray-200 hover:border-primary-blue transition-all overflow-hidden">
<div className="p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-4 min-w-0">
<div className="w-16 h-16 rounded-lg overflow-hidden bg-white border border-gray-200 shrink-0 flex items-center justify-center">
{image ? ( {image ? (
<img src={image} alt={name} className="w-full h-full object-cover" /> <img src={image} alt={name} className="w-full h-full object-cover" />
) : ( ) : (
<span className="text-sm font-bold text-primary-blue">{name?.[0]?.toUpperCase() || 'S'}</span> <span className="text-lg font-bold text-primary-blue">
{name?.[0]?.toUpperCase() || 'B'}
</span>
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0">
<p className="font-semibold text-gray-900 truncate">{name}</p> <p className="font-bold text-gray-800 truncate">{name}</p>
<p className="text-sm text-gray-500 truncate mt-1">{domain || 'Domain unavailable'}</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>
)}
</div> </div>
</div> </div>
<div className="mt-4 flex items-center justify-between gap-3"> <StatusBadge status={item.status} />
<span className="text-xs text-gray-500 font-medium"> </div>
{channel.websiteUrl ? 'Ready to scrape' : 'Use manual URL fallback'}
</span> {isScraped && item.business?.createdAt && (
<p className="text-xs text-gray-400 font-medium mt-4">
Added {new Date(item.business.createdAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
</p>
)}
{!isScraped && !hasWebsiteUrl && (
<p className="text-xs text-amber-700 font-medium mt-4">
A website URL could not be derived automatically for this sales channel.
</p>
)}
</div>
<div className="px-5 py-3 bg-white border-t border-gray-100 flex items-center justify-between gap-3">
{isScraped ? (
<>
<button <button
onClick={onImport} className="text-sm text-primary-blue font-semibold group-hover:underline disabled:opacity-60"
disabled={disabled || !channel.websiteUrl} onClick={() => onSelect(item.business)}
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={isOpening}
> >
{disabled ? 'Importing…' : 'Import'} {isOpening ? 'Opening…' : 'Manage →'}
</button> </button>
<button
onClick={() => onDelete(item.business)}
className="text-xs text-gray-600 hover:text-gray-700 font-medium transition"
>
Delete
</button>
</>
) : (
<>
<button
onClick={() => {
if (hasWebsiteUrl) {
onImport(item.channel);
return;
}
onFallback();
}}
disabled={isImporting}
className="text-sm text-primary-blue font-semibold group-hover:underline disabled:opacity-60"
>
{isImporting ? 'Scraping…' : hasWebsiteUrl ? 'Scrape →' : 'Use Fallback URL →'}
</button>
<span className="text-xs text-gray-500 font-medium">
{hasWebsiteUrl ? 'Ready to scrape' : 'Needs manual URL'}
</span>
</>
)}
</div> </div>
</div> </div>
); );
@ -135,29 +223,65 @@ export default function Businesses() {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const configuredApplicationIds = useMemo(() => ( const showUnifiedSalesChannelView = salesChannelsStatus === 'success';
new Set(
const unifiedEntries = useMemo(() => {
const matchedBusinessIds = new Set();
const businessByApplicationId = new Map(
businesses businesses
.map((business) => String(business?.applicationId || '').trim()) .map((business) => [getApplicationId(business), business])
.filter(Boolean) .filter(([applicationId]) => Boolean(applicationId))
) );
), [businesses]);
const availableSalesChannels = useMemo(() => ( const mergedEntries = salesChannels.map((channel, index) => {
salesChannels.filter((channel) => !configuredApplicationIds.has(getChannelId(channel))) const applicationId = getApplicationId(channel);
), [configuredApplicationIds, salesChannels]); const business = applicationId ? businessByApplicationId.get(applicationId) || null : null;
const showSalesChannelsSection = salesChannelsStatus === 'success' && availableSalesChannels.length > 0;
const filteredSalesChannels = useMemo(() => { if (business?.businessId) {
const query = salesChannelQuery.trim().toLowerCase(); matchedBusinessIds.add(business.businessId);
if (!query) return availableSalesChannels; }
return availableSalesChannels.filter((channel) => { return {
const name = String(getBusinessName(channel) || '').toLowerCase(); key: `channel:${applicationId || channel.name || channel.domain || index}`,
const domain = String(getBusinessDomain(channel) || '').toLowerCase(); status: business ? 'scraped' : 'not_scraped',
return name.includes(query) || domain.includes(query); applicationId,
business,
channel,
};
}); });
}, [availableSalesChannels, salesChannelQuery]);
const standaloneBusinesses = businesses
.filter((business) => !matchedBusinessIds.has(business.businessId))
.map((business) => ({
key: `business:${business.businessId}`,
status: 'scraped',
applicationId: getApplicationId(business),
business,
channel: null,
}));
return [...mergedEntries, ...standaloneBusinesses].sort((left, right) => {
if (left.status !== right.status) {
return left.status === 'not_scraped' ? -1 : 1;
}
return getBusinessName(left.business || left.channel)
.localeCompare(getBusinessName(right.business || right.channel));
});
}, [businesses, salesChannels]);
const filteredUnifiedEntries = useMemo(() => {
const query = salesChannelQuery.trim().toLowerCase();
if (!query) return unifiedEntries;
return unifiedEntries.filter((entry) => {
const entity = entry.business || entry.channel;
const name = String(getBusinessName(entity) || '').toLowerCase();
const domain = String(getBusinessDomain(entity) || '').toLowerCase();
const tagline = String(getBusinessTagline(entity) || '').toLowerCase();
return name.includes(query) || domain.includes(query) || tagline.includes(query);
});
}, [unifiedEntries, salesChannelQuery]);
const loadBusinesses = useCallback(async () => { const loadBusinesses = useCallback(async () => {
const res = await apiClient.get('/api/businesses'); const res = await apiClient.get('/api/businesses');
@ -210,11 +334,11 @@ export default function Businesses() {
} }
async function handleCreateFromSalesChannel(channel) { async function handleCreateFromSalesChannel(channel) {
const applicationId = getChannelId(channel); const applicationId = getApplicationId(channel);
if (!applicationId) return; if (!applicationId) return;
if (!channel.websiteUrl) { if (!channel.websiteUrl) {
setError('A website URL could not be derived from this sales channel. Please use Add Business and enter the URL manually.'); setError('A website URL could not be derived from this sales channel. Please use the fallback URL flow to continue.');
return; return;
} }
@ -252,162 +376,132 @@ export default function Businesses() {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="min-h-screen flex items-center justify-center bg-white">
<div className="w-8 h-8 border-2 border-refresh-active border-t-indigo-600 rounded-full animate-spin" /> <div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-gray-50 p-8"> <div className="min-h-screen bg-white p-5">
<div className="max-w-5xl 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-800 tracking-tight">
{businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business'} {showUnifiedSalesChannelView ? 'Your Sales Channels' : (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">
{showSalesChannelsSection {showUnifiedSalesChannelView
? 'Import from an active sales channel when available, or use the website URL fallback to scrape manually.' ? 'View every connected sales channel in one place and scrape the ones that are not onboarded yet.'
: 'Add a storefront URL and well scrape it to set up your business.'} : 'Add a storefront URL and well scrape it to set up your business.'}
</p> </p>
</div> </div>
{!showUnifiedSalesChannelView && (
<button <button
onClick={() => setShowModal(true)} onClick={() => setShowModal(true)}
className="px-4 py-2.5 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold shadow-sm transition" className="px-4 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition"
> >
+ Add Business + Add Business
</button> </button>
)}
</div> </div>
{error && ( {error && (
<div className="mb-6 px-4 py-3 rounded-md bg-red-50 border border-red-200 text-red-700 font-medium text-sm flex items-center justify-between"> <div className="mb-6 px-4 py-2 rounded-md bg-white border border-gray-200 text-gray-700 font-medium text-sm flex items-center justify-between">
{error} {error}
<button onClick={() => setError('')} className="text-red-500 hover:text-red-700 font-bold">&times;</button> <button onClick={() => setError('')} className="text-gray-600 hover:text-gray-700 font-bold">&times;</button>
</div> </div>
)} )}
<section className="mb-10"> {showUnifiedSalesChannelView ? (
<div className="mb-4">
<h2 className="text-lg font-bold text-gray-900 tracking-tight">Configured Businesses</h2>
<p className="text-sm text-gray-500 mt-1">Select a business to manage its SMS templates.</p>
</div>
{businesses.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{businesses.map((biz) => (
<div
key={biz.businessId}
className="group rounded-lg bg-white border border-gray-200 shadow-sm hover:border-primary-blue hover:ring-1 hover:ring-primary-blue transition-all overflow-hidden"
>
<button
className="w-full text-left p-6"
onClick={() => handleSelect(biz)}
disabled={selectingBusinessId === biz.businessId}
>
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-lg overflow-hidden bg-gray-50 border border-gray-200 shrink-0 flex items-center justify-center shadow-sm">
{getBusinessImage(biz) ? (
<img src={getBusinessImage(biz)} alt={getBusinessName(biz)} className="w-full h-full object-cover" />
) : (
<span className="text-lg font-bold text-primary-blue">
{getBusinessName(biz)?.[0]?.toUpperCase() || 'B'}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-900 truncate">{getBusinessName(biz)}</p>
{getBusinessDomain(biz) && (
<p className="text-xs text-gray-500 font-medium truncate mt-1">{getBusinessDomain(biz)}</p>
)}
{getBusinessTagline(biz) && (
<p className="text-sm text-gray-600 mt-3 leading-relaxed line-clamp-2">{getBusinessTagline(biz)}</p>
)}
<p className="text-xs text-gray-400 font-medium mt-3">
Added {new Date(biz.createdAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
</p>
{selectingBusinessId === biz.businessId && (
<div className="mt-3 inline-flex items-center gap-2 text-xs text-primary-blue font-semibold">
<span className="w-3.5 h-3.5 border-2 border-refresh-active border-t-indigo-600 rounded-full animate-spin" />
Opening
</div>
)}
</div>
</div>
</button>
<div className="px-6 py-3 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
<span className="text-xs text-primary-blue font-semibold group-hover:underline">Click to manage </span>
<button
onClick={(e) => { e.stopPropagation(); setDeleteTarget(biz); }}
className="text-xs text-red-500 hover:text-red-700 font-medium transition"
>
Delete
</button>
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed border-gray-300 bg-white p-8 text-center shadow-sm">
<p className="text-gray-900 font-semibold mb-1">No configured businesses yet.</p>
<p className="text-sm text-gray-500">Use Add Business to enter a storefront URL and get started.</p>
</div>
)}
</section>
{showSalesChannelsSection && (
<section> <section>
<div className="flex items-end justify-between gap-4 mb-4"> <div className="mb-4">
<div> <h2 className="text-lg font-bold text-gray-800 tracking-tight">Sales Channels</h2>
<h2 className="text-lg font-bold text-gray-900 tracking-tight">Active Sales Channels</h2> <p className="text-sm text-gray-500 mt-1">Scraped businesses and active sales channels are shown together here.</p>
<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> </div>
<button
onClick={() => setShowModal(true)} {unifiedEntries.length > 0 ? (
className="text-sm font-semibold text-primary-blue hover:text-primary-dark transition" <div className="rounded-lg border border-gray-200 bg-white p-5">
>
Use website URL fallback
</button>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm">
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Search Sales Channels</label> <label className="block text-sm font-semibold text-gray-700 mb-1.5">Search Sales Channels</label>
<input <input
type="text" type="text"
value={salesChannelQuery} value={salesChannelQuery}
onChange={(e) => setSalesChannelQuery(e.target.value)} onChange={(e) => setSalesChannelQuery(e.target.value)}
placeholder="Search by channel name or domain" placeholder="Search by name, domain, or description"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent" className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent"
/> />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{filteredSalesChannels.map((channel) => { {filteredUnifiedEntries.map((item) => (
const channelId = getChannelId(channel); <UnifiedBusinessCard
return ( key={item.key}
<SalesChannelCard item={item}
key={channelId} selectingBusinessId={selectingBusinessId}
channel={channel} creatingSalesChannelId={creatingSalesChannelId}
disabled={creatingSalesChannelId === channelId} onSelect={handleSelect}
onImport={() => handleCreateFromSalesChannel(channel)} onImport={handleCreateFromSalesChannel}
onDelete={setDeleteTarget}
onFallback={() => setShowModal(true)}
/> />
); ))}
})}
</div> </div>
{filteredSalesChannels.length === 0 && ( {filteredUnifiedEntries.length === 0 && (
<div className="mt-4 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-6 text-center"> <div className="mt-4 rounded-lg border border-dashed border-gray-300 bg-white p-5 text-center">
<p className="text-sm font-semibold text-gray-900">No active sales channels matched your search.</p> <p className="text-sm font-semibold text-gray-800">No 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> <p className="text-sm text-gray-500 mt-1">Try a different name or domain.</p>
</div> </div>
)} )}
</div> </div>
) : (
<div className="rounded-lg border border-dashed border-gray-300 bg-white p-5 text-center">
<p className="text-gray-800 font-semibold mb-1">No sales channels are available yet.</p>
<p className="text-sm text-gray-500 mb-4">Use the manual fallback only if you need to set up a storefront URL directly.</p>
<button
onClick={() => setShowModal(true)}
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-white text-sm font-medium transition"
>
Use website URL fallback
</button>
</div>
)}
</section>
) : (
<section className="mb-10">
<div className="mb-4">
<h2 className="text-lg font-bold text-gray-800 tracking-tight">Configured Businesses</h2>
<p className="text-sm text-gray-500 mt-1">Select a business to manage its SMS templates.</p>
</div>
{businesses.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{businesses.map((biz) => (
<UnifiedBusinessCard
key={`fallback:${biz.businessId}`}
item={{ key: `fallback:${biz.businessId}`, status: 'scraped', business: biz, channel: null }}
selectingBusinessId={selectingBusinessId}
creatingSalesChannelId={creatingSalesChannelId}
onSelect={handleSelect}
onImport={handleCreateFromSalesChannel}
onDelete={setDeleteTarget}
onFallback={() => setShowModal(true)}
/>
))}
</div>
) : (
<div className="rounded-lg border border-dashed border-gray-300 bg-white p-5 text-center">
<p className="text-gray-800 font-semibold mb-1">No configured businesses yet.</p>
<p className="text-sm text-gray-500">Use Add Business to enter a storefront URL and get started.</p>
</div>
)}
</section> </section>
)} )}
</div> </div>
{showModal && ( {showModal && (
<RegisterBusinessModal onClose={() => { setShowModal(false); loadBusinesses(); }} /> <RegisterBusinessModal onClose={() => { setShowModal(false); load(); }} />
)} )}
{createdBusiness && <BusinessCreatedModal business={createdBusiness} onClose={() => setCreatedBusiness(null)} />} {createdBusiness && <BusinessCreatedModal business={createdBusiness} onClose={() => setCreatedBusiness(null)} />}
{deleteTarget && ( {deleteTarget && (

View File

@ -102,18 +102,18 @@ const DEFAULT_EXPANDED_GROUPS = EVENT_GROUPS.reduce((acc, group) => {
const EVENT_TEMPLATE_STATUS_CONFIG = { const EVENT_TEMPLATE_STATUS_CONFIG = {
unselected: { unselected: {
label: 'No template selected', label: 'No template selected',
wrapper: 'border-gray-200 bg-gray-50 text-gray-500', wrapper: 'border-gray-200 bg-white text-gray-500',
dot: 'bg-gray-400', dot: 'bg-gray-400',
}, },
pending_whitelisting: { pending_whitelisting: {
label: 'Pending Whitelisting', label: 'Pending Whitelisting',
wrapper: 'border-amber-200 bg-amber-50 text-amber-700', wrapper: 'border-gray-200 bg-white text-gray-700',
dot: 'bg-amber-500', dot: 'bg-white0',
}, },
whitelisted: { whitelisted: {
label: 'Published', label: 'Published',
wrapper: 'border-green-200 bg-green-50 text-green-700', wrapper: 'border-gray-200 bg-white text-gray-700',
dot: 'bg-green-500', dot: 'bg-white0',
}, },
}; };
@ -507,7 +507,7 @@ export default function Events() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-refresh-active border-t-indigo-600 rounded-full animate-spin" /> <div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
</div> </div>
); );
} }
@ -519,7 +519,7 @@ export default function Events() {
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<div className="flex flex-col gap-4 pb-5 mb-6 border-b border-gray-200"> <div className="flex flex-col gap-4 pb-5 mb-6 border-b border-gray-200">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Events</h1> <h1 className="text-2xl font-bold text-gray-800 tracking-tight">Events</h1>
<p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for each order event.</p> <p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for each order event.</p>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@ -533,7 +533,7 @@ export default function Events() {
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search events" placeholder="Search events"
className="w-full rounded-lg border border-gray-300 bg-white py-3 pl-11 pr-10 text-sm font-medium text-gray-900 placeholder-gray-400 shadow-sm transition focus:border-primary-blue focus:outline-none focus:ring-2 focus:ring-indigo-100" className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-11 pr-10 text-sm font-medium text-gray-800 placeholder-gray-400 transition focus:border-primary-blue focus:outline-none focus:ring-2 focus:ring-indigo-100"
/> />
{searchTerm && ( {searchTerm && (
<button <button
@ -550,12 +550,12 @@ export default function Events() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="inline-flex items-center rounded-full border border-refresh-active bg-refresh-hover px-3 py-2 text-xs font-semibold text-primary-dark"> <span className="inline-flex items-center rounded-full border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-primary-dark">
{totalVisibleEvents} visible {totalVisibleEvents} visible
</span> </span>
<button <button
onClick={() => setShowAddForm((visible) => !visible)} onClick={() => setShowAddForm((visible) => !visible)}
className="px-4 py-2 rounded-lg bg-white border border-gray-300 shadow-sm text-sm text-gray-700 font-semibold hover:bg-gray-50 transition" className="px-4 py-2 rounded-lg bg-white border border-gray-300 text-sm text-gray-700 font-semibold hover:bg-white transition"
> >
{showAddForm ? 'Cancel' : '+ Add Event'} {showAddForm ? 'Cancel' : '+ Add Event'}
</button> </button>
@ -564,32 +564,32 @@ export default function Events() {
</div> </div>
{!readyToGenerate && ( {!readyToGenerate && (
<div className="mb-6 px-4 py-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-800 text-sm font-medium flex items-center gap-2"> <div className="mb-6 px-4 py-2 rounded-lg bg-white border border-gray-200 text-amber-800 text-sm font-medium flex items-center gap-2">
<span></span> <span></span>
<span>Set up and activate a <strong>cURL profile</strong> before generating templates.</span> <span>Set up and activate a <strong>cURL profile</strong> before generating templates.</span>
</div> </div>
)} )}
{error && ( {error && (
<div className="mb-6 px-4 py-3 rounded-md bg-red-50 border border-red-200 text-red-700 font-medium text-sm flex items-center justify-between"> <div className="mb-6 px-4 py-2 rounded-md bg-white border border-gray-200 text-gray-700 font-medium text-sm flex items-center justify-between">
{error} {error}
<button onClick={() => setError('')} className="text-red-500 hover:text-red-700 font-bold">&times;</button> <button onClick={() => setError('')} className="text-gray-600 hover:text-gray-700 font-bold">&times;</button>
</div> </div>
)} )}
{showAddForm && ( {showAddForm && (
<form onSubmit={handleAddEvent} className="mb-8 flex gap-3 p-5 rounded-lg bg-gray-50 border border-gray-200 shadow-sm"> <form onSubmit={handleAddEvent} className="mb-8 flex gap-3 p-5 rounded-lg bg-white border border-gray-200 ">
<input <input
value={newLabel} value={newLabel}
onChange={(e) => setNewLabel(e.target.value)} onChange={(e) => setNewLabel(e.target.value)}
placeholder="Event name (e.g. Return Initiated)" placeholder="Event name (e.g. Return Initiated)"
className="flex-1 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-primary-blue text-sm shadow-sm" className="flex-1 px-4 py-2 rounded-lg bg-white border border-gray-300 text-gray-800 placeholder-gray-400 font-medium focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm "
autoFocus autoFocus
/> />
<button <button
type="submit" type="submit"
disabled={addingEvent || !newLabel.trim()} disabled={addingEvent || !newLabel.trim()}
className="px-6 py-2.5 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-medium transition shadow-sm disabled:opacity-50" className="px-6 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-medium transition disabled:opacity-50"
> >
{addingEvent ? 'Adding…' : 'Add'} {addingEvent ? 'Adding…' : 'Add'}
</button> </button>
@ -597,8 +597,8 @@ export default function Events() {
)} )}
{groupedEvents.length === 0 ? ( {groupedEvents.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-12 text-center shadow-sm"> <div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-12 text-center ">
<p className="text-base font-semibold text-gray-900">No events match your search.</p> <p className="text-base font-semibold text-gray-800">No events match your search.</p>
<p className="mt-2 text-sm text-gray-500">Try a different keyword or clear the search to see the full lifecycle list.</p> <p className="mt-2 text-sm text-gray-500">Try a different keyword or clear the search to see the full lifecycle list.</p>
</div> </div>
) : ( ) : (
@ -607,26 +607,26 @@ export default function Events() {
const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id]; const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id];
return ( return (
<section key={group.id} className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"> <section key={group.id} className="overflow-hidden rounded-lg border border-gray-200 bg-white ">
<button <button
type="button" type="button"
onClick={() => toggleGroup(group.id)} onClick={() => toggleGroup(group.id)}
className="flex w-full items-start justify-between gap-4 px-6 py-5 text-left transition hover:bg-gray-50" className="flex w-full items-start justify-between gap-4 px-6 py-5 text-left transition hover:bg-white"
> >
<div className="flex min-w-0 items-start gap-4"> <div className="flex min-w-0 items-start gap-4">
<div className={`mt-1 h-3 w-3 rounded-full shadow-sm ${ <div className={`mt-1 h-3 w-3 rounded-full ${
group.id === 'fulfillment' ? 'bg-refresh-hover0' : group.id === 'fulfillment' ? 'bg-gray-400' :
group.id === 'delivery' ? 'bg-sky-500' : group.id === 'delivery' ? 'bg-sky-500' :
group.id === 'cancellations' ? 'bg-rose-500' : group.id === 'cancellations' ? 'bg-rose-500' :
group.id === 'returns' ? 'bg-amber-500' : group.id === 'returns' ? 'bg-white0' :
group.id === 'refunds' ? 'bg-emerald-500' : group.id === 'refunds' ? 'bg-emerald-500' :
group.id === 'rto' ? 'bg-fuchsia-500' : group.id === 'rto' ? 'bg-fuchsia-500' :
'bg-gray-500' 'bg-white0'
}`} /> }`} />
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-bold tracking-tight text-gray-900">{group.label}</h2> <h2 className="text-lg font-bold tracking-tight text-gray-800">{group.label}</h2>
<span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500"> <span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
{group.events.length} events {group.events.length} events
</span> </span>
</div> </div>
@ -643,7 +643,7 @@ export default function Events() {
</button> </button>
{isExpanded && ( {isExpanded && (
<div className="border-t border-gray-100 bg-gray-50/50 px-4 py-4 sm:px-6"> <div className="border-t border-gray-100 bg-white px-4 py-4 sm:px-6">
<div className="space-y-4"> <div className="space-y-4">
{group.events.map((event) => { {group.events.map((event) => {
const state = genState[event.slug] || 'idle'; const state = genState[event.slug] || 'idle';
@ -653,7 +653,7 @@ export default function Events() {
const canViewTemplate = templateStatus !== 'unselected'; const canViewTemplate = templateStatus !== 'unselected';
return ( return (
<div key={event.slug} className="rounded-lg bg-white border border-gray-200 shadow-sm overflow-hidden"> <div key={event.slug} className="rounded-lg bg-white border border-gray-200 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-6 py-5 gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between px-6 py-5 gap-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{event.isDefault ? ( {event.isDefault ? (
@ -663,14 +663,14 @@ export default function Events() {
) : ( ) : (
<button <button
onClick={() => handleDelete(event.slug)} onClick={() => handleDelete(event.slug)}
className="mt-0.5 w-6 h-6 rounded-full bg-red-50 hover:bg-red-100 flex items-center justify-center border border-red-100 text-red-500 transition shrink-0" className="mt-0.5 w-6 h-6 rounded-full bg-white hover:bg-red-100 flex items-center justify-center border border-gray-200 text-gray-600 transition shrink-0"
title="Delete event" title="Delete event"
> >
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button> </button>
)} )}
<div> <div>
<h3 className="text-base font-bold text-gray-900 tracking-tight">{event.label}</h3> <h3 className="text-base font-bold text-gray-800 tracking-tight">{event.label}</h3>
</div> </div>
</div> </div>
@ -678,7 +678,7 @@ export default function Events() {
<span <span
title={statusConfig.label} title={statusConfig.label}
aria-label={statusConfig.label} aria-label={statusConfig.label}
className={`inline-flex h-9 w-9 items-center justify-center rounded-full border shadow-sm ${statusConfig.wrapper}`} className={`inline-flex h-9 w-9 items-center justify-center rounded-full border ${statusConfig.wrapper}`}
> >
<span className={`h-2.5 w-2.5 rounded-full ${statusConfig.dot}`} /> <span className={`h-2.5 w-2.5 rounded-full ${statusConfig.dot}`} />
</span> </span>
@ -686,7 +686,7 @@ export default function Events() {
<button <button
type="button" type="button"
onClick={() => navigate(`/${businessId}/templates?event=${encodeURIComponent(event.slug)}`)} onClick={() => navigate(`/${businessId}/templates?event=${encodeURIComponent(event.slug)}`)}
className="px-3.5 py-2 rounded-lg bg-white border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50 transition shadow-sm" className="px-3.5 py-2 rounded-lg bg-white border border-gray-300 text-sm font-medium text-gray-700 hover:bg-white transition "
> >
View in Templates View in Templates
</button> </button>
@ -694,10 +694,10 @@ export default function Events() {
<button <button
onClick={() => handleGenerate(event.slug)} onClick={() => handleGenerate(event.slug)}
disabled={state === 'loading' || !readyToGenerate} disabled={state === 'loading' || !readyToGenerate}
className={`px-4 py-2 rounded-lg text-sm font-medium transition shadow-sm flex items-center gap-2 disabled:opacity-50 ${ className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 disabled:opacity-50 ${
state === 'done' || state === 'selected' state === 'done' || state === 'selected'
? 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50' ? 'bg-white border border-gray-300 text-gray-700 hover:bg-white'
: 'bg-refresh-hover border border-refresh-active text-primary-dark hover:bg-indigo-100' : 'bg-white border border-gray-200 text-primary-dark hover:bg-indigo-100'
}`} }`}
> >
{state === 'loading' ? ( {state === 'loading' ? (
@ -712,7 +712,7 @@ export default function Events() {
</div> </div>
{eventVariants.length > 0 && ( {eventVariants.length > 0 && (
<div className="border-t border-gray-100 bg-gray-50/50 px-6 py-5 space-y-4"> <div className="border-t border-gray-100 bg-white px-6 py-5 space-y-4">
<p className="text-xs text-gray-500 font-bold uppercase tracking-wider">Review, edit, and choose a variant</p> <p className="text-xs text-gray-500 font-bold uppercase tracking-wider">Review, edit, and choose a variant</p>
<div className="grid gap-4"> <div className="grid gap-4">
{eventVariants.map((variant, index) => { {eventVariants.map((variant, index) => {
@ -743,9 +743,9 @@ export default function Events() {
return ( return (
<div <div
key={variantKey} key={variantKey}
className={`rounded-lg border bg-white p-5 shadow-sm transition ${ className={`rounded-lg border bg-white p-5 transition ${
isSelectingThis isSelectingThis
? 'border-primary-blue ring-2 ring-indigo-100' ? 'border-primary-blue '
: 'border-gray-200 hover:border-gray-300' : 'border-gray-200 hover:border-gray-300'
}`} }`}
> >
@ -753,26 +753,26 @@ export default function Events() {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className={`text-[11px] font-bold px-2 py-1 rounded-full border ${ <span className={`text-[11px] font-bold px-2 py-1 rounded-full border ${
isEdited isEdited
? 'bg-amber-50 border-amber-200 text-amber-700' ? 'bg-white border-gray-200 text-gray-700'
: 'bg-gray-50 border-gray-200 text-gray-600' : 'bg-white border-gray-200 text-gray-600'
}`}> }`}>
{isEdited ? 'Edited Draft' : 'Original Draft'} {isEdited ? 'Edited Draft' : 'Original Draft'}
</span> </span>
{validationStatus === 'checking' && ( {validationStatus === 'checking' && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-blue-50 border-blue-200 text-blue-700"> <span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-white border-gray-200 text-gray-700">
Checking edit Checking edit
</span> </span>
)} )}
{validationStatus === 'approved' && currentMatchesCheckedText && ( {validationStatus === 'approved' && currentMatchesCheckedText && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-green-50 border-green-200 text-green-700"> <span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-white border-gray-200 text-gray-700">
Edit passed check Edit passed check
</span> </span>
)} )}
{validationStatus === 'rejected' && currentMatchesCheckedText && ( {validationStatus === 'rejected' && currentMatchesCheckedText && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-red-50 border-red-200 text-red-700"> <span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-white border-gray-200 text-gray-700">
Needs changes Needs changes
</span> </span>
)} )}
@ -790,14 +790,14 @@ export default function Events() {
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => handleVariableMenuToggle(variantKey)} onClick={() => handleVariableMenuToggle(variantKey)}
disabled={!canInsertVariable} disabled={!canInsertVariable}
className="text-xs px-3 py-2 rounded-md bg-white border border-refresh-active text-primary-dark font-semibold hover:bg-refresh-hover transition disabled:opacity-50 disabled:cursor-not-allowed" className="text-xs px-3 py-2 rounded-md bg-white border border-gray-200 text-primary-dark font-semibold hover:bg-white transition disabled:opacity-50 disabled:cursor-not-allowed"
> >
# Add Variable # Add Variable
</button> </button>
{openVariableMenuKey === variantKey && ( {openVariableMenuKey === variantKey && (
<div className="absolute right-0 z-20 mt-2 w-56 rounded-lg border border-gray-200 bg-white shadow-sm overflow-hidden"> <div className="absolute right-0 z-20 mt-2 w-56 rounded-lg border border-gray-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50"> <div className="px-4 py-2 border-b border-gray-100 bg-white">
<p className="text-[11px] font-bold uppercase tracking-wider text-gray-500">Insert DLT Variable</p> <p className="text-[11px] font-bold uppercase tracking-wider text-gray-500">Insert DLT Variable</p>
</div> </div>
<div className="py-1"> <div className="py-1">
@ -807,7 +807,7 @@ export default function Events() {
type="button" type="button"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => insertVariableToken(event.slug, index, option.token)} onClick={() => insertVariableToken(event.slug, index, option.token)}
className="w-full px-4 py-3 text-left hover:bg-refresh-hover transition flex items-center justify-between gap-3" className="w-full px-4 py-2 text-left hover:bg-white transition flex items-center justify-between gap-3"
> >
<span className="text-sm font-semibold text-gray-800">{option.label}</span> <span className="text-sm font-semibold text-gray-800">{option.label}</span>
<span className="text-xs font-mono text-primary-dark">{option.token}</span> <span className="text-xs font-mono text-primary-dark">{option.token}</span>
@ -831,10 +831,10 @@ export default function Events() {
onSelect={(e) => trackTextareaSelection(variantKey, e.target)} onSelect={(e) => trackTextareaSelection(variantKey, e.target)}
onKeyUp={(e) => trackTextareaSelection(variantKey, e.target)} onKeyUp={(e) => trackTextareaSelection(variantKey, e.target)}
rows={4} rows={4}
className={`w-full rounded-lg border px-4 py-3 text-sm text-gray-800 font-mono leading-relaxed resize-y focus:outline-none focus:ring-2 ${ className={`w-full rounded-lg border px-4 py-2 text-sm text-gray-800 font-mono leading-relaxed resize-y focus:outline-none focus:ring-2 ${
isEdited isEdited
? 'border-amber-200 bg-amber-50/40 focus:ring-amber-200 focus:border-amber-300' ? 'border-gray-200 bg-white/40 focus:ring-amber-200 focus:border-amber-300'
: 'border-gray-200 bg-gray-50 focus:ring-indigo-100 focus:border-primary-blue' : 'border-gray-200 bg-white focus:ring-indigo-100 focus:border-primary-blue'
}`} }`}
/> />
@ -842,12 +842,12 @@ export default function Events() {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className={`text-xs font-semibold px-2.5 py-1 rounded-md border ${ <span className={`text-xs font-semibold px-2.5 py-1 rounded-md border ${
tooLong tooLong
? 'bg-red-50 border-red-200 text-red-700' ? 'bg-white border-gray-200 text-gray-700'
: 'bg-gray-100 border-gray-200 text-gray-600' : 'bg-gray-100 border-gray-200 text-gray-600'
}`}> }`}>
{currentText.length} / {MAX_SMS_LENGTH} {currentText.length} / {MAX_SMS_LENGTH}
</span> </span>
<span className="text-xs font-semibold px-2.5 py-1 rounded-md border bg-refresh-hover border-refresh-active text-primary-dark"> <span className="text-xs font-semibold px-2.5 py-1 rounded-md border bg-white border-gray-200 text-primary-dark">
DLT vars: {dltTokenCount} DLT vars: {dltTokenCount}
</span> </span>
</div> </div>
@ -855,7 +855,7 @@ export default function Events() {
{isEdited && ( {isEdited && (
<button <button
onClick={() => handleRevertVariant(event.slug, index)} onClick={() => handleRevertVariant(event.slug, index)}
className="text-xs px-3 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50 transition" className="text-xs px-3 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-semibold hover:bg-white transition"
> >
Revert to original Revert to original
</button> </button>
@ -863,33 +863,33 @@ export default function Events() {
</div> </div>
{isEdited && ( {isEdited && (
<div className="mt-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3"> <div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2">
<p className="text-[11px] font-bold uppercase tracking-wider text-gray-500 mb-2">Original generated version</p> <p className="text-[11px] font-bold uppercase tracking-wider text-gray-500 mb-2">Original generated version</p>
<p className="text-sm text-gray-600 font-mono leading-relaxed">{originalText}</p> <p className="text-sm text-gray-600 font-mono leading-relaxed">{originalText}</p>
</div> </div>
)} )}
{invalidDltTokens.length > 0 && ( {invalidDltTokens.length > 0 && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"> <div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-amber-800">
Unsupported DLT variable token{invalidDltTokens.length > 1 ? 's' : ''}: <span className="font-mono">{invalidDltTokens.join(', ')}</span>. Unsupported DLT variable token{invalidDltTokens.length > 1 ? 's' : ''}: <span className="font-mono">{invalidDltTokens.join(', ')}</span>.
Use only {DLT_VARIABLE_OPTIONS.map((option) => option.token).join(', ')}. Use only {DLT_VARIABLE_OPTIONS.map((option) => option.token).join(', ')}.
</div> </div>
)} )}
{hasMalformedDltToken && invalidDltTokens.length === 0 && ( {hasMalformedDltToken && invalidDltTokens.length === 0 && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"> <div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-amber-800">
Finish or remove incomplete DLT placeholder text before checking or selecting this edit. Finish or remove incomplete DLT placeholder text before checking or selecting this edit.
</div> </div>
)} )}
{tooLong && ( {tooLong && (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-700">
Shorten this template to {MAX_SMS_LENGTH} characters or less before checking or using the edited version. Shorten this template to {MAX_SMS_LENGTH} characters or less before checking or using the edited version.
</div> </div>
)} )}
{validationStatus === 'rejected' && currentMatchesCheckedText && draft.why && ( {validationStatus === 'rejected' && currentMatchesCheckedText && draft.why && (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-700">
<span className="font-semibold">Why it did not pass:</span> {draft.why} <span className="font-semibold">Why it did not pass:</span> {draft.why}
</div> </div>
)} )}
@ -908,7 +908,7 @@ export default function Events() {
<button <button
onClick={() => handleSelect(event.slug, originalText, index)} onClick={() => handleSelect(event.slug, originalText, index)}
disabled={isSelectingThis || isSelectingAnotherVariant} disabled={isSelectingThis || isSelectingAnotherVariant}
className="text-xs px-4 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-bold hover:bg-gray-50 transition disabled:opacity-60 disabled:cursor-not-allowed" className="text-xs px-4 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-bold hover:bg-white transition disabled:opacity-60 disabled:cursor-not-allowed"
> >
{isSelectingThis ? 'Selecting…' : 'Use original'} {isSelectingThis ? 'Selecting…' : 'Use original'}
</button> </button>

View File

@ -195,31 +195,31 @@ export default function GlobalSms() {
</div> </div>
{error && ( {error && (
<div className="px-4 py-3 rounded-md bg-delayed-bg border border-delayed-border text-error-text text-sm font-medium flex justify-between items-center"> <div className="px-4 py-2 rounded-md bg-white border border-gray-200 text-error-text text-sm font-medium flex justify-between items-center">
{error} {error}
<button onClick={() => setError('')} className="text-error-text hover:text-red-900 font-bold">&times;</button> <button onClick={() => setError('')} className="text-error-text hover:text-red-900 font-bold">&times;</button>
</div> </div>
)} )}
{success && ( {success && (
<div className="px-4 py-3 rounded-md bg-badge-bg border border-badge-border text-badge-text text-sm font-medium flex justify-between items-center"> <div className="px-4 py-2 rounded-md bg-white border border-gray-200 text-gray-700 text-sm font-medium flex justify-between items-center">
{success} {success}
<button onClick={() => setSuccess('')} className="text-badge-text hover:opacity-75 font-bold">&times;</button> <button onClick={() => setSuccess('')} className="text-gray-700 hover:opacity-75 font-bold">&times;</button>
</div> </div>
)} )}
{/* Active Profile Setup Review Block */} {/* Active Profile Setup Review Block */}
{activeProfile && ( {activeProfile && (
<div className={`p-6 rounded-lg border ${isSetupComplete ? 'border-primary-blue bg-refresh-hover' : 'border-tags-border bg-tags-bg'} shadow-sm`}> <div className={`p-5 rounded-lg border ${isSetupComplete ? 'border-primary-blue bg-white' : 'border-gray-200 bg-white'} `}>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<h3 className="font-bold text-text-primary text-lg">Active Setup: {activeProfile.name}</h3> <h3 className="font-bold text-text-primary text-lg">Active Setup: {activeProfile.name}</h3>
{isSetupComplete ? ( {isSetupComplete ? (
<span className="px-3 py-1 bg-badge-bg text-badge-text border border-badge-border rounded-full text-xs font-bold uppercase tracking-wide">Setup Complete</span> <span className="px-3 py-1 bg-white text-gray-700 border border-gray-200 rounded-full text-xs font-bold uppercase tracking-wide">Setup Complete</span>
) : ( ) : (
<span className="px-3 py-1 bg-tags-bg text-tags-text border border-tags-border rounded-full text-xs font-bold uppercase tracking-wide">Missing Information</span> <span className="px-3 py-1 bg-white text-gray-700 border border-gray-200 rounded-full text-xs font-bold uppercase tracking-wide">Missing Information</span>
)} )}
</div> </div>
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-5">
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-medium text-text-primary">Parsed Provider Data:</p> <p className="text-sm font-medium text-text-primary">Parsed Provider Data:</p>
<ul className="space-y-2 text-sm"> <ul className="space-y-2 text-sm">
@ -243,7 +243,7 @@ export default function GlobalSms() {
</div> </div>
{!isSetupComplete && ( {!isSetupComplete && (
<div className="bg-surface-white p-4 rounded-lg border border-border-main shadow-sm"> <div className="bg-surface-white p-4 rounded-lg border border-border-main ">
<p className="text-sm font-semibold text-text-primary mb-3">Please fill in the missing fields:</p> <p className="text-sm font-semibold text-text-primary mb-3">Please fill in the missing fields:</p>
<form onSubmit={handleProviderSubmit} className="space-y-3"> <form onSubmit={handleProviderSubmit} className="space-y-3">
{missingFields.includes('providerName') && ( {missingFields.includes('providerName') && (
@ -280,7 +280,7 @@ export default function GlobalSms() {
<button <button
type="submit" type="submit"
disabled={savingProvider} disabled={savingProvider}
className="w-full py-2 bg-primary-blue hover:bg-primary-dark text-white text-sm font-bold rounded shadow-sm transition disabled:opacity-50" className="w-full py-2 bg-primary-blue hover:bg-primary-dark text-white text-sm font-bold rounded transition disabled:opacity-50"
> >
{savingProvider ? 'Saving...' : 'Save Required Details'} {savingProvider ? 'Saving...' : 'Save Required Details'}
</button> </button>
@ -293,7 +293,7 @@ export default function GlobalSms() {
<p className="text-center text-sm font-medium text-text-muted">Your active cURL profile is fully configured.</p> <p className="text-center text-sm font-medium text-text-muted">Your active cURL profile is fully configured.</p>
<button <button
onClick={() => navigate(eventsPath)} onClick={() => navigate(eventsPath)}
className="px-6 py-3 bg-primary-blue hover:bg-primary-dark text-white rounded-lg shadow font-semibold text-sm transition w-full" className="px-6 py-2 bg-primary-blue hover:bg-primary-dark text-white rounded-lg font-semibold text-sm transition w-full"
> >
Continue to Events Continue to Events
</button> </button>
@ -310,17 +310,17 @@ export default function GlobalSms() {
profiles.map(p => { profiles.map(p => {
const isActive = p.id === activeProfileId; const isActive = p.id === activeProfileId;
return ( return (
<div key={p.id} className={`p-5 rounded-lg border ${isActive ? 'border-primary-blue bg-refresh-hover' : 'border-border-main bg-surface-white'} shadow-sm flex flex-col md:flex-row gap-4 items-start md:items-center justify-between transition-colors`}> <div key={p.id} className={`p-5 rounded-lg border ${isActive ? 'border-primary-blue bg-white' : 'border-border-main bg-surface-white'} flex flex-col md:flex-row gap-4 items-start md:items-center justify-between transition-colors`}>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-3 mb-1">
<h3 className="font-bold text-text-primary text-base truncate">{p.name}</h3> <h3 className="font-bold text-text-primary text-base truncate">{p.name}</h3>
{isActive && ( {isActive && (
<span className="px-2 py-0.5 rounded text-[11px] font-bold tracking-wider uppercase bg-badge-bg text-badge-text border border-badge-border shrink-0"> <span className="px-2 py-0.5 rounded text-[11px] font-bold tracking-wider uppercase bg-white text-gray-700 border border-gray-200 shrink-0">
Active Profile Active Profile
</span> </span>
)} )}
{p.isDefault && !isActive && ( {p.isDefault && !isActive && (
<span className="px-2 py-0.5 rounded text-[11px] font-bold tracking-wider uppercase bg-tags-bg text-tags-text border border-tags-border shrink-0"> <span className="px-2 py-0.5 rounded text-[11px] font-bold tracking-wider uppercase bg-white text-gray-700 border border-gray-200 shrink-0">
Default Default
</span> </span>
)} )}
@ -334,7 +334,7 @@ export default function GlobalSms() {
{!isActive && ( {!isActive && (
<button <button
onClick={() => handleActivate(p.id)} onClick={() => handleActivate(p.id)}
className="px-4 py-2 bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold rounded-lg shadow-sm transition" className="px-4 py-2 bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold rounded-lg transition"
> >
Use this cURL Use this cURL
</button> </button>
@ -348,7 +348,7 @@ export default function GlobalSms() {
{profiles.length > 1 && ( {profiles.length > 1 && (
<button <button
onClick={() => handleDelete(p.id)} onClick={() => handleDelete(p.id)}
className="px-3 py-2 border border-border-main text-text-muted hover:text-error-text hover:border-error-text hover:bg-delayed-bg rounded-lg text-sm font-medium transition" className="px-3 py-2 border border-border-main text-text-muted hover:text-error-text hover:border-error-text hover:bg-white rounded-lg text-sm font-medium transition"
> >
Delete Delete
</button> </button>
@ -365,7 +365,7 @@ export default function GlobalSms() {
</div> </div>
{/* Inline Form (Create / Edit) */} {/* Inline Form (Create / Edit) */}
<div className="bg-surface-white border border-border-main rounded-lg shadow-sm overflow-hidden"> <div className="bg-surface-white border border-border-main rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-border-main bg-table-header flex items-center justify-between"> <div className="px-6 py-4 border-b border-border-main bg-table-header flex items-center justify-between">
<h3 className="font-bold text-text-primary text-md"> <h3 className="font-bold text-text-primary text-md">
{editingId ? 'Edit Profile' : 'Add New Profile'} {editingId ? 'Edit Profile' : 'Add New Profile'}
@ -376,7 +376,7 @@ export default function GlobalSms() {
</button> </button>
)} )}
</div> </div>
<div className="p-6"> <div className="p-5">
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div> <div>
<label className="block text-sm font-semibold text-text-primary mb-1.5">Profile Name</label> <label className="block text-sm font-semibold text-text-primary mb-1.5">Profile Name</label>
@ -385,7 +385,7 @@ export default function GlobalSms() {
value={formName} value={formName}
onChange={e => setFormName(e.target.value)} onChange={e => setFormName(e.target.value)}
placeholder="e.g. Production SMS, Staging Twilio" placeholder="e.g. Production SMS, Staging Twilio"
className="w-full px-4 py-2.5 rounded-lg bg-page-bg border border-border-main text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue transition text-sm" className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue transition text-sm"
required required
/> />
</div> </div>
@ -395,7 +395,7 @@ export default function GlobalSms() {
value={formCurl} value={formCurl}
onChange={e => setFormCurl(e.target.value)} onChange={e => setFormCurl(e.target.value)}
placeholder="curl --request POST --url ..." placeholder="curl --request POST --url ..."
className="w-full h-40 px-4 py-3 rounded-lg font-mono text-sm bg-page-bg border border-border-main text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue transition resize-none leading-relaxed" className="w-full h-40 px-4 py-2 rounded-lg font-mono text-sm bg-page-bg border border-border-main text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue transition resize-none leading-relaxed"
required required
spellCheck="false" spellCheck="false"
/> />
@ -415,7 +415,7 @@ export default function GlobalSms() {
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
className="px-6 py-2.5 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-semibold text-sm shadow-sm transition disabled:opacity-50 flex items-center justify-center gap-2" className="px-6 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-semibold text-sm transition disabled:opacity-50 flex items-center justify-center gap-2"
> >
{saving ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving</> : (editingId ? 'Update Profile' : 'Save Profile')} {saving ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving</> : (editingId ? 'Update Profile' : 'Save Profile')}
</button> </button>
@ -424,7 +424,7 @@ export default function GlobalSms() {
type="button" type="button"
onClick={handleAddClick} onClick={handleAddClick}
disabled={saving} disabled={saving}
className="px-5 py-2.5 rounded-lg border border-border-main text-text-primary hover:bg-page-bg font-medium text-sm transition" className="px-5 py-2 rounded-lg border border-border-main text-text-primary hover:bg-page-bg font-medium text-sm transition"
> >
Cancel Edit Cancel Edit
</button> </button>

View File

@ -73,7 +73,7 @@ export default function Providers() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-refresh-active border-t-indigo-600 rounded-full animate-spin" /> <div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
</div> </div>
); );
} }
@ -81,7 +81,7 @@ export default function Providers() {
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<div className="pb-5 mb-6 border-b border-gray-200"> <div className="pb-5 mb-6 border-b border-gray-200">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Provider Configuration</h1> <h1 className="text-2xl font-bold text-gray-800 tracking-tight">Provider Configuration</h1>
<p className="text-sm text-gray-500 mt-1 font-medium">Edit the provider details stored on the active cURL profile.</p> <p className="text-sm text-gray-500 mt-1 font-medium">Edit the provider details stored on the active cURL profile.</p>
{activeProfile && ( {activeProfile && (
<p className="text-xs text-gray-500 mt-2 font-semibold uppercase tracking-wide"> <p className="text-xs text-gray-500 mt-2 font-semibold uppercase tracking-wide">
@ -91,20 +91,20 @@ export default function Providers() {
</div> </div>
{error && ( {error && (
<div className="mb-6 px-4 py-3 rounded-md bg-red-50 border border-red-200 text-red-700 font-medium text-sm flex items-center justify-between"> <div className="mb-6 px-4 py-2 rounded-md bg-white border border-gray-200 text-gray-700 font-medium text-sm flex items-center justify-between">
{error} {error}
<button onClick={() => setError('')} className="text-red-500 hover:text-red-700 font-bold">&times;</button> <button onClick={() => setError('')} className="text-gray-600 hover:text-gray-700 font-bold">&times;</button>
</div> </div>
)} )}
{success && ( {success && (
<div className="mb-6 px-4 py-3 rounded-md bg-green-50 border border-green-200 text-green-700 font-medium text-sm flex items-center justify-between shadow-sm"> <div className="mb-6 px-4 py-2 rounded-md bg-white border border-gray-200 text-gray-700 font-medium text-sm flex items-center justify-between ">
{success} {success}
<button onClick={() => setSuccess('')} className="text-green-500 hover:text-green-700 font-bold">&times;</button> <button onClick={() => setSuccess('')} className="text-gray-600 hover:text-gray-700 font-bold">&times;</button>
</div> </div>
)} )}
<form onSubmit={handleSave} className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden"> <form onSubmit={handleSave} className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="p-6 space-y-6"> <div className="p-5 space-y-6">
<div> <div>
<label className={`block text-sm font-semibold mb-1.5 tracking-wide ${!form.providerName ? 'text-error-text' : 'text-text-primary'}`}> <label className={`block text-sm font-semibold mb-1.5 tracking-wide ${!form.providerName ? 'text-error-text' : 'text-text-primary'}`}>
Provider Name {(!form.providerName) && <span className="text-error-text">*</span>} Provider Name {(!form.providerName) && <span className="text-error-text">*</span>}
@ -113,12 +113,12 @@ export default function Providers() {
type="text" type="text"
value={form.providerName} value={form.providerName}
onChange={e => handleChange('providerName', e.target.value)} onChange={e => handleChange('providerName', e.target.value)}
className={`w-full px-4 py-2.5 rounded-lg bg-surface-white border ${!form.providerName ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} text-text-primary placeholder-placeholder-bg font-medium focus:outline-none focus:ring-2 focus:border-transparent transition text-sm shadow-sm`} className={`w-full px-4 py-2 rounded-lg bg-surface-white border ${!form.providerName ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} text-text-primary placeholder-placeholder-bg font-medium focus:outline-none focus:ring-2 focus:border-transparent transition text-sm `}
placeholder="e.g. MSG91, Gupshup" placeholder="e.g. MSG91, Gupshup"
/> />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div> <div>
<label className={`block text-sm font-semibold mb-1.5 tracking-wide ${!form.senderId ? 'text-error-text' : 'text-text-primary'}`}> <label className={`block text-sm font-semibold mb-1.5 tracking-wide ${!form.senderId ? 'text-error-text' : 'text-text-primary'}`}>
DLT Sender ID {(!form.senderId) && <span className="text-error-text">*</span>} DLT Sender ID {(!form.senderId) && <span className="text-error-text">*</span>}
@ -128,7 +128,7 @@ export default function Providers() {
value={form.senderId} value={form.senderId}
onChange={e => handleChange('senderId', e.target.value.toUpperCase())} onChange={e => handleChange('senderId', e.target.value.toUpperCase())}
maxLength={6} maxLength={6}
className={`w-full px-4 py-2.5 rounded-lg bg-surface-white border ${!form.senderId ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} text-text-primary font-mono tracking-widest placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:border-transparent transition text-sm shadow-sm uppercase`} className={`w-full px-4 py-2 rounded-lg bg-surface-white border ${!form.senderId ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} text-text-primary font-mono tracking-widest placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:border-transparent transition text-sm uppercase`}
placeholder="6 CHARS" placeholder="6 CHARS"
/> />
<p className="text-xs text-gray-500 mt-2 font-medium">Exactly 6 alphabetic characters (e.g. MOKOBA).</p> <p className="text-xs text-gray-500 mt-2 font-medium">Exactly 6 alphabetic characters (e.g. MOKOBA).</p>
@ -142,7 +142,7 @@ export default function Providers() {
type="text" type="text"
value={form.dltEntityId} value={form.dltEntityId}
onChange={e => handleChange('dltEntityId', e.target.value)} onChange={e => handleChange('dltEntityId', e.target.value)}
className={`w-full px-4 py-2.5 rounded-lg bg-surface-white border ${!form.dltEntityId ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} text-text-primary font-mono placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:border-transparent transition text-sm shadow-sm`} className={`w-full px-4 py-2 rounded-lg bg-surface-white border ${!form.dltEntityId ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} text-text-primary font-mono placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:border-transparent transition text-sm `}
placeholder="19-digit DLT PE ID" placeholder="19-digit DLT PE ID"
/> />
</div> </div>
@ -154,18 +154,18 @@ export default function Providers() {
type="password" type="password"
value={form.authKey} value={form.authKey}
onChange={e => handleChange('authKey', e.target.value)} onChange={e => handleChange('authKey', e.target.value)}
className="w-full px-4 py-2.5 rounded-lg bg-surface-white border border-border-main text-text-primary font-mono placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent transition text-sm shadow-sm" className="w-full px-4 py-2 rounded-lg bg-surface-white border border-border-main text-text-primary font-mono placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent transition text-sm "
placeholder="Authorization key for your SMS provider" placeholder="Authorization key for your SMS provider"
/> />
<p className="text-xs text-gray-500 mt-2 font-medium">Used as the Authorization header in your SMS requests.</p> <p className="text-xs text-gray-500 mt-2 font-medium">Used as the Authorization header in your SMS requests.</p>
</div> </div>
</div> </div>
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex justify-end"> <div className="px-6 py-4 bg-white border-t border-gray-200 flex justify-end">
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
className="px-6 py-2.5 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-semibold text-sm shadow-sm transition disabled:opacity-50 flex items-center justify-center gap-2" className="px-6 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-semibold text-sm transition disabled:opacity-50 flex items-center justify-center gap-2"
> >
{saving ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving</> : 'Save Configuration'} {saving ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving</> : 'Save Configuration'}
</button> </button>

View File

@ -6,8 +6,8 @@ import TestSmsModal from '../components/TestSmsModal';
const STATUS_CONFIG = { const STATUS_CONFIG = {
generated: { label: 'Generated', bg: 'bg-page-bg', text: 'text-text-muted', border: 'border-border-main' }, generated: { label: 'Generated', bg: 'bg-page-bg', text: 'text-text-muted', border: 'border-border-main' },
pending_whitelisting: { label: 'Pending Whitelisting', bg: 'bg-tags-bg', text: 'text-tags-text', border: 'border-tags-border' }, pending_whitelisting: { label: 'Pending Whitelisting', bg: 'bg-white', text: 'text-gray-700', border: 'border-gray-200' },
whitelisted: { label: 'Published', bg: 'bg-badge-bg', text: 'text-badge-text', border: 'border-badge-border' }, whitelisted: { label: 'Published', bg: 'bg-white', text: 'text-gray-700', border: 'border-gray-200' },
}; };
export default function Templates() { export default function Templates() {
@ -103,7 +103,7 @@ export default function Templates() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-refresh-active border-t-indigo-600 rounded-full animate-spin" /> <div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
</div> </div>
); );
} }
@ -111,12 +111,12 @@ export default function Templates() {
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<div className="pb-5 mb-6 border-b border-gray-200"> <div className="pb-5 mb-6 border-b border-gray-200">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Templates</h1> <h1 className="text-2xl font-bold text-gray-800 tracking-tight">Templates</h1>
<p className="text-sm text-gray-500 mt-1 font-medium">Track whitelisting status and test your SMS templates.</p> <p className="text-sm text-gray-500 mt-1 font-medium">Track whitelisting status and test your SMS templates.</p>
</div> </div>
{error && ( {error && (
<div className="mb-6 px-4 py-3 rounded-md bg-delayed-bg border border-delayed-border text-error-text font-medium text-sm flex items-center justify-between"> <div className="mb-6 px-4 py-2 rounded-md bg-white border border-gray-200 text-error-text font-medium text-sm flex items-center justify-between">
{error} {error}
<button onClick={() => setError('')} className="text-error-text hover:text-red-900 font-bold">&times;</button> <button onClick={() => setError('')} className="text-error-text hover:text-red-900 font-bold">&times;</button>
</div> </div>
@ -146,7 +146,7 @@ export default function Templates() {
</div> </div>
{templates.length === 0 ? ( {templates.length === 0 ? (
<div className="text-center py-16 bg-surface-white border border-border-main rounded-lg shadow-sm"> <div className="text-center py-16 bg-surface-white border border-border-main rounded-lg ">
<div className="w-16 h-16 rounded-full bg-page-bg flex items-center justify-center mx-auto mb-4 border border-border-soft"> <div className="w-16 h-16 rounded-full bg-page-bg flex items-center justify-center mx-auto mb-4 border border-border-soft">
<svg className="w-8 h-8 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> <svg className="w-8 h-8 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
</div> </div>
@ -186,15 +186,15 @@ export default function Templates() {
delete templateCardRefs.current[tmpl.eventSlug]; delete templateCardRefs.current[tmpl.eventSlug];
} }
}} }}
className={`rounded-lg bg-white border shadow-sm overflow-hidden transition-all duration-300 ${ className={`rounded-lg bg-white border overflow-hidden transition-all duration-300 ${
highlightedEventSlug === tmpl.eventSlug highlightedEventSlug === tmpl.eventSlug
? 'border-primary-blue ring-2 ring-indigo-200 animate-pulse' ? 'border-primary-blue animate-pulse'
: 'border-gray-200' : 'border-gray-200'
}`} }`}
> >
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between"> <div className="px-6 py-4 border-b border-gray-100 bg-white flex items-center justify-between">
<div> <div>
<h3 className="text-base font-bold text-gray-900 capitalize tracking-tight"> <h3 className="text-base font-bold text-gray-800 capitalize tracking-tight">
{tmpl.eventLabel || tmpl.eventSlug.replace(/_/g, ' ')} {tmpl.eventLabel || tmpl.eventSlug.replace(/_/g, ' ')}
</h3> </h3>
<p className="text-xs text-gray-500 font-mono mt-0.5">{tmpl.eventSlug}</p> <p className="text-xs text-gray-500 font-mono mt-0.5">{tmpl.eventSlug}</p>
@ -204,16 +204,16 @@ export default function Templates() {
</span> </span>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-5 space-y-4">
<div> <div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Bound cURL Profile</label> <label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Bound cURL Profile</label>
{boundProfile ? ( {boundProfile ? (
<div className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200 text-sm text-gray-700"> <div className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-white border border-gray-200 text-sm text-gray-700">
<span className="font-semibold">{boundProfile.name}</span> <span className="font-semibold">{boundProfile.name}</span>
<span className="text-gray-400 font-mono text-xs">{boundProfile.id}</span> <span className="text-gray-400 font-mono text-xs">{boundProfile.id}</span>
</div> </div>
) : ( ) : (
<div className="px-4 py-3 rounded-lg border border-red-200 bg-red-50 text-sm text-red-700 font-medium"> <div className="px-4 py-2 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 font-medium">
{boundProfileMessage} {boundProfileMessage}
</div> </div>
)} )}
@ -221,7 +221,7 @@ export default function Templates() {
<div> <div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Selected Template</label> <label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Selected Template</label>
<div className="p-4 rounded-lg bg-gray-50 border border-gray-200 font-mono text-sm text-gray-800 leading-relaxed break-words"> <div className="p-4 rounded-lg bg-white border border-gray-200 font-mono text-sm text-gray-800 leading-relaxed break-words">
{tmpl.selectedTemplate} {tmpl.selectedTemplate}
</div> </div>
</div> </div>
@ -229,7 +229,7 @@ export default function Templates() {
{tmpl.templateId && ( {tmpl.templateId && (
<div> <div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">DLT Template ID</label> <label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">DLT Template ID</label>
<p className="font-mono text-sm text-primary-dark bg-refresh-hover border border-refresh-active px-3 py-2 rounded-lg inline-block"> <p className="font-mono text-sm text-primary-dark bg-white border border-gray-200 px-3 py-2 rounded-lg inline-block">
{tmpl.templateId} {tmpl.templateId}
</p> </p>
</div> </div>
@ -240,7 +240,7 @@ export default function Templates() {
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Variable Mappings</label> <label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Variable Mappings</label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{Object.entries(tmpl.variableMap).map(([key, val]) => ( {Object.entries(tmpl.variableMap).map(([key, val]) => (
<div key={key} className="flex items-center gap-2 text-xs bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5"> <div key={key} className="flex items-center gap-2 text-xs bg-white border border-gray-200 rounded-md px-3 py-1.5">
<span className="font-mono text-primary-dark font-bold">{key}</span> <span className="font-mono text-primary-dark font-bold">{key}</span>
<span className="text-gray-400"></span> <span className="text-gray-400"></span>
<span className="font-medium text-gray-700">{val}</span> <span className="font-medium text-gray-700">{val}</span>
@ -254,7 +254,7 @@ export default function Templates() {
{!isBoundProfileMissing && tmpl.status === 'pending_whitelisting' && ( {!isBoundProfileMissing && tmpl.status === 'pending_whitelisting' && (
<button <button
onClick={() => setWhitelistTarget(tmpl)} onClick={() => setWhitelistTarget(tmpl)}
className="px-4 py-2 rounded-lg bg-tags-text hover:bg-orange-700 text-white text-sm font-semibold transition shadow-sm border border-orange-600" className="px-4 py-2 rounded-lg bg-tags-text hover:bg-orange-700 text-white text-sm font-semibold transition border border-orange-600"
> >
Publish Publish
</button> </button>
@ -262,7 +262,7 @@ export default function Templates() {
{!isBoundProfileMissing && tmpl.status === 'whitelisted' && ( {!isBoundProfileMissing && tmpl.status === 'whitelisted' && (
<button <button
onClick={() => setTestTarget(tmpl)} onClick={() => setTestTarget(tmpl)}
className="px-4 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition shadow-sm flex items-center gap-2" className="px-4 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition flex items-center gap-2"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Test SMS Test SMS

View File

@ -116,8 +116,19 @@ export function normalizeChannelsPayload(data) {
return channels.map(normalizeChannel); return channels.map(normalizeChannel);
} }
export function getApplicationId(entity) {
return (
entity?.applicationId
|| entity?.salesChannelId
|| entity?.application_id
|| entity?.id
|| entity?._id
|| ''
);
}
export function getChannelId(channel) { export function getChannelId(channel) {
return channel?.salesChannelId || channel?.id || channel?._id || ''; return getApplicationId(channel);
} }
export function isChannelActive(channel) { export function isChannelActive(channel) {

View File

@ -1,99 +1,12 @@
import { getRuntimeCompanyId, getRuntimeExtensionId } from './runtimeContext'; import apiClient from '../api/client';
import { import { normalizeChannelsPayload } from './businessProfile';
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() { export async function fetchActiveSalesChannels() {
const companyId = getRuntimeCompanyId(); const response = await apiClient.get('/api/platform/sales-channels');
const extensionId = getRuntimeExtensionId(); return normalizeChannelsPayload(
response?.data?.salesChannels
if (!companyId) { || response?.data?.channels
throw new Error('Company ID is unavailable for fetching sales channels.'); || response?.data?.items
} || []
);
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 @@ 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() : '';
@ -28,6 +29,13 @@ function createFdkExtension() {
} }
try { try {
const storage = createFdkStorage(process.env.FDK_STORAGE_CONNECTION_STRING)
|| new MemoryStorage('sms_extension_');
if (storage instanceof MemoryStorage) {
console.warn('[FDK] FDK_STORAGE_CONNECTION_STRING is not set. Falling back to in-memory session storage.');
}
return setupFdk({ return setupFdk({
api_key: apiKey, api_key: apiKey,
api_secret: apiSecret, api_secret: apiSecret,
@ -41,7 +49,7 @@ function createFdkExtension() {
console.log(`[FDK] uninstall callback received for company ${companyId || 'unknown'}`); console.log(`[FDK] uninstall callback received for company ${companyId || 'unknown'}`);
}, },
}, },
storage: new MemoryStorage('sms_extension_'), storage,
}); });
} catch (error) { } catch (error) {
console.warn(`[FDK] Failed to initialize FDK: ${error.message}`); console.warn(`[FDK] Failed to initialize FDK: ${error.message}`);

View File

@ -6,6 +6,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const businessesRoutes = require('./routes/businesses'); const businessesRoutes = require('./routes/businesses');
const platformRoutes = require('./routes/platform');
const { fdkExtension, isFdkConfigured } = require('./fdk'); const { fdkExtension, isFdkConfigured } = require('./fdk');
const app = express(); const app = express();
@ -29,6 +30,9 @@ app.get('/api/health', (req, res) => res.json({
if (fdkExtension) { if (fdkExtension) {
app.use(fdkExtension.fdkHandler); app.use(fdkExtension.fdkHandler);
app.use('/api/platform', fdkExtension.platformApiRoutes, platformRoutes);
} else {
app.use('/api/platform', platformRoutes);
} }
// Routes // Routes

139
server/package-lock.json generated
View File

@ -17,6 +17,7 @@
"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.16.3",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
@ -1668,6 +1669,96 @@
"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",
@ -1681,6 +1772,45 @@
"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",
@ -2007,6 +2137,15 @@
"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,6 +17,7 @@
"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.16.3",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -0,0 +1,124 @@
const { Pool } = require('pg');
const BaseStorage = require('@gofynd/fdk-extension-javascript/express/storage/base_storage');
const DEFAULT_TABLE_NAME = 'fdk session storage';
function normalizeText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function quoteIdentifier(value) {
return `"${String(value).replace(/"/g, '""')}"`;
}
function coerceJsonValue(value) {
if (!value) return null;
if (typeof value === 'object') return value;
try {
return JSON.parse(value);
} catch {
return null;
}
}
class PostgresFdkStorage extends BaseStorage {
constructor(connectionString, prefixKey = 'sms_extension_', tableName = DEFAULT_TABLE_NAME) {
super(prefixKey);
this.pool = new Pool({ connectionString });
this.tableName = quoteIdentifier(tableName);
}
getFullKey(key) {
return `${this.prefixKey}${key}`;
}
async get(key) {
const fullKey = this.getFullKey(key);
const result = await this.pool.query(
`SELECT value, expires_at
FROM ${this.tableName}
WHERE storage_key = $1
ORDER BY updated_at DESC NULLS LAST, id DESC
LIMIT 1`,
[fullKey]
);
if (!result.rows.length) return null;
const row = result.rows[0];
const expiresAt = row.expires_at ? new Date(row.expires_at) : null;
if (expiresAt && expiresAt.getTime() <= Date.now()) {
await this.del(key);
return null;
}
return coerceJsonValue(row.value);
}
async set(key, value) {
return this.save(key, value, null);
}
async setex(key, value, ttl) {
const ttlSeconds = Number(ttl);
const expiresAt = Number.isFinite(ttlSeconds) && ttlSeconds > 0
? new Date(Date.now() + ttlSeconds * 1000)
: null;
return this.save(key, value, expiresAt);
}
async save(key, value, expiresAt) {
const fullKey = this.getFullKey(key);
const serializedValue = JSON.stringify(value || {});
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const updateResult = await client.query(
`UPDATE ${this.tableName}
SET value = $2,
expires_at = $3,
updated_at = NOW()
WHERE storage_key = $1`,
[fullKey, serializedValue, expiresAt]
);
if (updateResult.rowCount === 0) {
await client.query(
`INSERT INTO ${this.tableName} (storage_key, value, expires_at)
VALUES ($1, $2, $3)`,
[fullKey, serializedValue, expiresAt]
);
}
await client.query('COMMIT');
return 'OK';
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async del(key) {
const fullKey = this.getFullKey(key);
await this.pool.query(`DELETE FROM ${this.tableName} WHERE storage_key = $1`, [fullKey]);
}
}
function createFdkStorage(connectionString) {
const normalizedConnectionString = normalizeText(connectionString);
if (!normalizedConnectionString) return null;
return new PostgresFdkStorage(normalizedConnectionString);
}
module.exports = {
createFdkStorage,
PostgresFdkStorage,
};

171
server/routes/platform.js Normal file
View File

@ -0,0 +1,171 @@
const express = require('express');
const router = express.Router();
function normalizeText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeScopeId(value) {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
return '';
}
function normalizeDomainName(value) {
const rawValue = normalizeText(value);
if (!rawValue) return '';
return rawValue
.replace(/^https?:\/\//i, '')
.replace(/\/.*$/, '')
.trim();
}
function normalizeWebsiteUrl(value) {
const domain = normalizeDomainName(value);
if (!domain) return '';
try {
return new URL(`https://${domain}`).toString().replace(/\/$/, '');
} catch {
return '';
}
}
function normalizeDomainRecord(domain = {}) {
const name = normalizeDomainName(
domain?.name
|| domain?.domain_url
|| domain?.url
|| domain?.host
);
if (!name) return null;
return {
name,
verified: Boolean(domain?.verified),
is_primary: Boolean(domain?.is_primary),
is_shortlink: Boolean(domain?.is_shortlink),
};
}
function rankDomain(domain) {
if (!domain) return -1;
let score = 0;
if (domain.is_primary) score += 4;
if (domain.verified) score += 2;
if (!domain.is_shortlink) score += 1;
return score;
}
function pickPreferredDomain(domains = []) {
const normalizedDomains = domains.map(normalizeDomainRecord).filter(Boolean);
if (!normalizedDomains.length) return { domain: '', domains: [] };
normalizedDomains.sort((left, right) => rankDomain(right) - rankDomain(left));
return {
domain: normalizedDomains[0]?.name || '',
domains: normalizedDomains,
};
}
function getImageUrl(value) {
if (!value) return '';
if (typeof value === 'string') return normalizeText(value);
return normalizeText(value?.secure_url || value?.url);
}
async function listAllApplications(platformClient) {
const pageSize = 100;
let pageNo = 1;
const items = [];
while (pageNo <= 20) {
const response = await platformClient.configuration.getApplications({ pageNo, pageSize });
const pageItems = Array.isArray(response?.items) ? response.items : [];
items.push(...pageItems);
if (!response?.page?.has_next || !pageItems.length) break;
pageNo += 1;
}
return items;
}
async function enrichApplication(platformClient, application) {
const applicationId = normalizeScopeId(application?._id);
if (!applicationId) return null;
let detail = application;
let domains = Array.isArray(application?.domains) ? application.domains : [];
const needsDetails = !normalizeText(application?.description) || !getImageUrl(application?.logo);
const needsDomains = !domains.length;
if (needsDetails || needsDomains) {
const configurationClient = platformClient.application(applicationId).configuration;
const [detailResult, domainsResult] = await Promise.allSettled([
needsDetails ? configurationClient.getApplicationById() : Promise.resolve(null),
needsDomains ? configurationClient.getDomains() : Promise.resolve(null),
]);
if (detailResult.status === 'fulfilled' && detailResult.value) {
detail = { ...application, ...detailResult.value };
}
if (domainsResult.status === 'fulfilled' && Array.isArray(domainsResult.value?.domains)) {
domains = domainsResult.value.domains;
}
}
const { domain, domains: normalizedDomains } = pickPreferredDomain(
domains.length ? domains : [detail?.domain]
);
return {
_id: applicationId,
id: applicationId,
applicationId,
salesChannelId: applicationId,
name: normalizeText(detail?.name || application?.name) || `Sales Channel ${applicationId}`,
description: normalizeText(detail?.description || application?.description),
domain,
domains: normalizedDomains,
websiteUrl: normalizeWebsiteUrl(domain),
is_active: detail?.is_active !== false && application?.is_active !== false,
logoUrl: getImageUrl(detail?.logo || application?.logo || detail?.mobile_logo || application?.mobile_logo),
bannerUrl: getImageUrl(detail?.banner || application?.banner),
appType: normalizeText(detail?.app_type || application?.app_type),
channelType: normalizeText(detail?.channel_type || application?.channel_type),
createdAt: normalizeText(detail?.created_at || application?.created_at),
updatedAt: normalizeText(detail?.modified_at || application?.modified_at),
};
}
router.get('/sales-channels', async (req, res) => {
try {
if (!req.platformClient) {
return res.status(503).json({ error: 'Platform client is unavailable for sales-channel fetch' });
}
const applications = await listAllApplications(req.platformClient);
const channels = [];
for (const application of applications) {
const enrichedChannel = await enrichApplication(req.platformClient, application);
if (enrichedChannel?.is_active) {
channels.push(enrichedChannel);
}
}
res.json({ salesChannels: channels });
} catch (error) {
console.error('Sales-channel fetch error:', error.message);
res.status(502).json({ error: error.message || 'Failed to fetch sales channels' });
}
});
module.exports = router;