Try a method of storing in boltic table
This commit is contained in:
parent
37792c1704
commit
0922dbc7c1
|
|
@ -16,6 +16,7 @@ RUN npm ci --omit=dev
|
|||
|
||||
COPY server/index.js ./
|
||||
COPY server/fdk.js ./
|
||||
COPY server/postgresFdkStorage.js ./
|
||||
COPY server/config ./config
|
||||
COPY server/routes ./routes
|
||||
COPY server/services ./services
|
||||
|
|
|
|||
|
|
@ -18,14 +18,14 @@ function SubLayout({ children }) {
|
|||
{hasGlobalSms && (
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</header>
|
||||
<div className="flex-1 p-8 overflow-auto">
|
||||
<div className="flex-1 p-5 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
|
@ -42,7 +42,7 @@ function BusinessGuard({ children, isGlobalSmsRoute }) {
|
|||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default function ImagePicker({ currentImage, onSelect }) {
|
|||
}
|
||||
|
||||
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 (
|
||||
|
|
@ -37,14 +37,14 @@ export default function ImagePicker({ currentImage, onSelect }) {
|
|||
onClick={() => onSelect(img.url)}
|
||||
className={`relative rounded-lg overflow-hidden border-2 aspect-video transition-all ${
|
||||
isSelected
|
||||
? 'border-primary-blue ring-2 ring-primary-blue ring-opacity-50 shadow-md'
|
||||
: 'border-transparent hover:border-gray-300 opacity-80 hover:opacity-100 shadow-sm'
|
||||
? 'border-primary-blue -blue '
|
||||
: '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" />
|
||||
<div className={`absolute inset-0 bg-primary-blue/20 transition-opacity ${isSelected ? 'opacity-100' : 'opacity-0'}`} />
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -26,16 +26,16 @@ export default function RegisterBusinessModal({ onClose }) {
|
|||
|
||||
return (
|
||||
<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' && (
|
||||
<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>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Business Added!</h2>
|
||||
<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-800 mb-2">Business Added!</h2>
|
||||
<p className="text-gray-500 text-sm mb-6 font-medium">Your business has been registered successfully.</p>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
|
@ -45,7 +45,7 @@ export default function RegisterBusinessModal({ onClose }) {
|
|||
{(status === 'idle' || status === 'loading' || status === 'error') && (
|
||||
<>
|
||||
<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">
|
||||
Enter the storefront website URL and we'll scrape it to detect the brand and set up your business.
|
||||
</p>
|
||||
|
|
@ -60,13 +60,13 @@ export default function RegisterBusinessModal({ onClose }) {
|
|||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://yourstore.com"
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
|
|
@ -74,14 +74,14 @@ export default function RegisterBusinessModal({ onClose }) {
|
|||
type="button"
|
||||
onClick={onClose}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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' ? (
|
||||
<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Analysing…</>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const SVG_ICONS = {
|
|||
function TopLevelStatus({ done, active }) {
|
||||
if (done) {
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
|
|
@ -31,7 +31,7 @@ function TopLevelStatus({ done, 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" />;
|
||||
|
|
@ -40,7 +40,7 @@ function TopLevelStatus({ done, active }) {
|
|||
function StageMarker({ done, active, enabled }) {
|
||||
if (done) {
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
|
|
@ -49,7 +49,7 @@ function StageMarker({ done, active, enabled }) {
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -126,18 +126,18 @@ export default function Sidebar() {
|
|||
<div className="px-5 py-5 border-b border-gray-100">
|
||||
<button
|
||||
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">←</span>
|
||||
<span>Switch Business</span>
|
||||
</button>
|
||||
{activeBusiness && (
|
||||
<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'}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -161,10 +161,10 @@ export default function Sidebar() {
|
|||
{item.enabled ? (
|
||||
<NavLink
|
||||
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
|
||||
? 'bg-gray-100/70 text-gray-900'
|
||||
: 'text-gray-500 hover:text-gray-900 hover:bg-gray-50'
|
||||
? 'bg-gray-100/70 text-gray-800'
|
||||
: 'text-gray-500 hover:text-gray-800 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
{SVG_ICONS[item.id]}
|
||||
|
|
@ -186,7 +186,7 @@ export default function Sidebar() {
|
|||
) : (
|
||||
<div
|
||||
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]}
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
|
|
@ -203,13 +203,13 @@ export default function Sidebar() {
|
|||
{item.substeps.map((substep) => (
|
||||
<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">
|
||||
{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
|
||||
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
|
||||
? 'bg-refresh-hover text-primary-blue font-semibold'
|
||||
: 'text-gray-500 font-medium hover:text-gray-900 hover:bg-gray-50'
|
||||
? 'bg-white text-primary-blue font-semibold'
|
||||
: 'text-gray-500 font-medium hover:text-gray-800 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
{substep.label}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ export default function TestSmsModal({ businessId, template, onClose }) {
|
|||
|
||||
return (
|
||||
<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="w-12 h-12 rounded-full bg-green-50 flex items-center justify-center mx-auto mb-4">
|
||||
<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-white flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-xl">📱</span>
|
||||
</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">
|
||||
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>
|
||||
|
|
@ -40,7 +40,7 @@ export default function TestSmsModal({ businessId, template, onClose }) {
|
|||
{!result ? (
|
||||
<form onSubmit={handleSend} className="space-y-4">
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -52,7 +52,7 @@ export default function TestSmsModal({ businessId, template, onClose }) {
|
|||
value={toNumber}
|
||||
onChange={e => setToNumber(e.target.value)}
|
||||
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
|
||||
required
|
||||
/>
|
||||
|
|
@ -64,14 +64,14 @@ export default function TestSmsModal({ businessId, template, onClose }) {
|
|||
type="button"
|
||||
onClick={onClose}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
|
|
@ -79,21 +79,21 @@ export default function TestSmsModal({ businessId, template, onClose }) {
|
|||
</form>
|
||||
) : (
|
||||
<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.statusCode && <span className="ml-2 opacity-60">HTTP {result.statusCode}</span>}
|
||||
</div>
|
||||
{result.response && (
|
||||
<div>
|
||||
<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)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -104,8 +104,8 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
|
||||
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="bg-surface-white border border-border-main rounded-lg p-8 w-full max-w-md shadow-sm 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="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-white border border-gray-200 flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-xl">✅</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
)}
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -141,7 +141,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
type="text"
|
||||
value={providerForm.providerName}
|
||||
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"
|
||||
autoFocus
|
||||
required
|
||||
|
|
@ -156,7 +156,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
type="text"
|
||||
value={providerForm.senderId}
|
||||
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"
|
||||
maxLength={6}
|
||||
required
|
||||
|
|
@ -171,7 +171,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
type="text"
|
||||
value={providerForm.dltEntityId}
|
||||
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"
|
||||
required
|
||||
/>
|
||||
|
|
@ -183,7 +183,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
type="button"
|
||||
onClick={onClose}
|
||||
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
|
||||
</button>
|
||||
|
|
@ -195,7 +195,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
if (field === 'dltEntityId') return !providerForm.dltEntityId.trim();
|
||||
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'}
|
||||
</button>
|
||||
|
|
@ -210,7 +210,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
value={templateId}
|
||||
onChange={e => setTemplateId(e.target.value)}
|
||||
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
|
||||
required
|
||||
/>
|
||||
|
|
@ -223,7 +223,7 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
value={toNumber}
|
||||
onChange={e => setToNumber(e.target.value)}
|
||||
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
|
||||
/>
|
||||
<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"
|
||||
onClick={onClose}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -13,26 +13,26 @@ const NAV_CARDS = [
|
|||
function DeleteConfirmModal({ brandName, onCancel, onConfirm, deleting }) {
|
||||
return (
|
||||
<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="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mx-auto mb-4">
|
||||
<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-white flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-xl">🗑</span>
|
||||
</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">
|
||||
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>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
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'}
|
||||
</button>
|
||||
|
|
@ -64,8 +64,8 @@ export default function Brand() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-8 h-8 border-2 border-refresh-active border-t-indigo-600 rounded-full animate-spin" />
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -73,18 +73,18 @@ export default function Brand() {
|
|||
// — WELCOME SCREEN —
|
||||
if (!brand) {
|
||||
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="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
|
||||
</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">
|
||||
Generate TRAI-compliant SMS templates for your Fynd store. We'll scrape your website and use AI to extract your brand context automatically.
|
||||
</p>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
|
@ -96,17 +96,17 @@ export default function Brand() {
|
|||
|
||||
// — BRAND DETAIL PAGE —
|
||||
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">
|
||||
|
||||
{/* 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-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>
|
||||
<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}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 font-medium tracking-wide">
|
||||
|
|
@ -116,7 +116,7 @@ export default function Brand() {
|
|||
</div>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
|
@ -142,7 +142,7 @@ export default function Brand() {
|
|||
{brand.colors.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<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 }}
|
||||
title={c}
|
||||
/>
|
||||
|
|
@ -156,11 +156,11 @@ export default function Brand() {
|
|||
|
||||
{/* Brand images */}
|
||||
{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>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
{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
|
||||
src={url}
|
||||
alt={`brand image ${i + 1}`}
|
||||
|
|
@ -182,10 +182,10 @@ export default function Brand() {
|
|||
<Link
|
||||
key={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>
|
||||
<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>
|
||||
</Link>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -5,36 +5,36 @@ import { useBusiness } from '../context/BusinessContext';
|
|||
import RegisterBusinessModal from '../components/RegisterBusinessModal';
|
||||
import { fetchActiveSalesChannels } from '../utils/fyndSalesChannels';
|
||||
import {
|
||||
getApplicationId,
|
||||
getBusinessDomain,
|
||||
getBusinessImage,
|
||||
getBusinessName,
|
||||
getBusinessTagline,
|
||||
getChannelId,
|
||||
} from '../utils/businessProfile';
|
||||
|
||||
function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
|
||||
return (
|
||||
<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="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mx-auto mb-4">
|
||||
<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-white flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-xl">🗑</span>
|
||||
</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">
|
||||
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>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
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'}
|
||||
</button>
|
||||
|
|
@ -52,13 +52,13 @@ function BusinessCreatedModal({ business, onClose }) {
|
|||
|
||||
return (
|
||||
<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="w-14 h-14 rounded-full bg-green-50 text-green-600 flex items-center justify-center mx-auto mb-4 text-2xl">✓</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2 text-center">Business Added!</h2>
|
||||
<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-white text-green-600 flex items-center justify-center mx-auto mb-4 text-2xl">✓</div>
|
||||
<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>
|
||||
<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="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 ? (
|
||||
<img src={image} alt={name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
|
|
@ -74,7 +74,7 @@ function BusinessCreatedModal({ business, onClose }) {
|
|||
</div>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
|
@ -83,37 +83,125 @@ function BusinessCreatedModal({ business, onClose }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SalesChannelCard({ channel, disabled, onImport }) {
|
||||
const name = getBusinessName(channel);
|
||||
const domain = getBusinessDomain(channel);
|
||||
const image = getBusinessImage(channel);
|
||||
function StatusBadge({ status }) {
|
||||
const isScraped = status === 'scraped';
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition hover:border-primary-blue hover:shadow-md">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-12 h-12 rounded-lg overflow-hidden bg-gray-50 border border-gray-200 shrink-0 flex items-center justify-center">
|
||||
{image ? (
|
||||
<img src={image} alt={name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-sm font-bold text-primary-blue">{name?.[0]?.toUpperCase() || 'S'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-gray-900 truncate">{name}</p>
|
||||
<p className="text-sm text-gray-500 truncate mt-1">{domain || 'Domain unavailable'}</p>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${
|
||||
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 ? (
|
||||
<img src={image} alt={name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg font-bold text-primary-blue">
|
||||
{name?.[0]?.toUpperCase() || 'B'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-gray-800 truncate">{name}</p>
|
||||
{domain && (
|
||||
<p className="text-xs text-gray-500 font-medium truncate mt-1">{domain}</p>
|
||||
)}
|
||||
{tagline && (
|
||||
<p className="text-sm text-gray-600 mt-3 leading-relaxed line-clamp-2">{tagline}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
|
||||
{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="mt-4 flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-gray-500 font-medium">
|
||||
{channel.websiteUrl ? 'Ready to scrape' : 'Use manual URL fallback'}
|
||||
</span>
|
||||
<button
|
||||
onClick={onImport}
|
||||
disabled={disabled || !channel.websiteUrl}
|
||||
className="px-3.5 py-2 rounded-lg border border-green-200 bg-green-50 text-green-700 text-sm font-semibold transition hover:bg-green-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{disabled ? 'Importing…' : 'Import'}
|
||||
</button>
|
||||
|
||||
<div className="px-5 py-3 bg-white border-t border-gray-100 flex items-center justify-between gap-3">
|
||||
{isScraped ? (
|
||||
<>
|
||||
<button
|
||||
className="text-sm text-primary-blue font-semibold group-hover:underline disabled:opacity-60"
|
||||
onClick={() => onSelect(item.business)}
|
||||
disabled={isOpening}
|
||||
>
|
||||
{isOpening ? 'Opening…' : 'Manage →'}
|
||||
</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>
|
||||
);
|
||||
|
|
@ -135,29 +223,65 @@ export default function Businesses() {
|
|||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const configuredApplicationIds = useMemo(() => (
|
||||
new Set(
|
||||
const showUnifiedSalesChannelView = salesChannelsStatus === 'success';
|
||||
|
||||
const unifiedEntries = useMemo(() => {
|
||||
const matchedBusinessIds = new Set();
|
||||
const businessByApplicationId = new Map(
|
||||
businesses
|
||||
.map((business) => String(business?.applicationId || '').trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
), [businesses]);
|
||||
.map((business) => [getApplicationId(business), business])
|
||||
.filter(([applicationId]) => Boolean(applicationId))
|
||||
);
|
||||
|
||||
const availableSalesChannels = useMemo(() => (
|
||||
salesChannels.filter((channel) => !configuredApplicationIds.has(getChannelId(channel)))
|
||||
), [configuredApplicationIds, salesChannels]);
|
||||
const showSalesChannelsSection = salesChannelsStatus === 'success' && availableSalesChannels.length > 0;
|
||||
const mergedEntries = salesChannels.map((channel, index) => {
|
||||
const applicationId = getApplicationId(channel);
|
||||
const business = applicationId ? businessByApplicationId.get(applicationId) || null : null;
|
||||
|
||||
const filteredSalesChannels = useMemo(() => {
|
||||
const query = salesChannelQuery.trim().toLowerCase();
|
||||
if (!query) return availableSalesChannels;
|
||||
if (business?.businessId) {
|
||||
matchedBusinessIds.add(business.businessId);
|
||||
}
|
||||
|
||||
return availableSalesChannels.filter((channel) => {
|
||||
const name = String(getBusinessName(channel) || '').toLowerCase();
|
||||
const domain = String(getBusinessDomain(channel) || '').toLowerCase();
|
||||
return name.includes(query) || domain.includes(query);
|
||||
return {
|
||||
key: `channel:${applicationId || channel.name || channel.domain || index}`,
|
||||
status: business ? 'scraped' : 'not_scraped',
|
||||
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 res = await apiClient.get('/api/businesses');
|
||||
|
|
@ -210,11 +334,11 @@ export default function Businesses() {
|
|||
}
|
||||
|
||||
async function handleCreateFromSalesChannel(channel) {
|
||||
const applicationId = getChannelId(channel);
|
||||
const applicationId = getApplicationId(channel);
|
||||
if (!applicationId) return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -252,162 +376,132 @@ export default function Businesses() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-8 h-8 border-2 border-refresh-active border-t-indigo-600 rounded-full animate-spin" />
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">
|
||||
{businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business'}
|
||||
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">
|
||||
{showUnifiedSalesChannelView ? 'Your Sales Channels' : (businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{showSalesChannelsSection
|
||||
? 'Import from an active sales channel when available, or use the website URL fallback to scrape manually.'
|
||||
{showUnifiedSalesChannelView
|
||||
? 'View every connected sales channel in one place and scrape the ones that are not onboarded yet.'
|
||||
: 'Add a storefront URL and we’ll scrape it to set up your business.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
+ Add Business
|
||||
</button>
|
||||
{!showUnifiedSalesChannelView && (
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition"
|
||||
>
|
||||
+ Add Business
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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}
|
||||
<button onClick={() => setError('')} className="text-red-500 hover:text-red-700 font-bold">×</button>
|
||||
<button onClick={() => setError('')} className="text-gray-600 hover:text-gray-700 font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="mb-10">
|
||||
<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>
|
||||
<div className="flex items-end justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900 tracking-tight">Active Sales Channels</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">These are pulled directly from Commerce and can be scraped into businesses with one click.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="text-sm font-semibold text-primary-blue hover:text-primary-dark transition"
|
||||
>
|
||||
Use website URL fallback
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm">
|
||||
{showUnifiedSalesChannelView ? (
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Search Sales Channels</label>
|
||||
<input
|
||||
type="text"
|
||||
value={salesChannelQuery}
|
||||
onChange={(e) => setSalesChannelQuery(e.target.value)}
|
||||
placeholder="Search by channel name or domain"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent"
|
||||
/>
|
||||
<h2 className="text-lg font-bold text-gray-800 tracking-tight">Sales Channels</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Scraped businesses and active sales channels are shown together here.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{filteredSalesChannels.map((channel) => {
|
||||
const channelId = getChannelId(channel);
|
||||
return (
|
||||
<SalesChannelCard
|
||||
key={channelId}
|
||||
channel={channel}
|
||||
disabled={creatingSalesChannelId === channelId}
|
||||
onImport={() => handleCreateFromSalesChannel(channel)}
|
||||
{unifiedEntries.length > 0 ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Search Sales Channels</label>
|
||||
<input
|
||||
type="text"
|
||||
value={salesChannelQuery}
|
||||
onChange={(e) => setSalesChannelQuery(e.target.value)}
|
||||
placeholder="Search by name, domain, or description"
|
||||
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>
|
||||
|
||||
{filteredSalesChannels.length === 0 && (
|
||||
<div className="mt-4 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-6 text-center">
|
||||
<p className="text-sm font-semibold text-gray-900">No active sales channels matched your search.</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Use the website URL fallback if you want to scrape a storefront directly.</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{filteredUnifiedEntries.map((item) => (
|
||||
<UnifiedBusinessCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
selectingBusinessId={selectingBusinessId}
|
||||
creatingSalesChannelId={creatingSalesChannelId}
|
||||
onSelect={handleSelect}
|
||||
onImport={handleCreateFromSalesChannel}
|
||||
onDelete={setDeleteTarget}
|
||||
onFallback={() => setShowModal(true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredUnifiedEntries.length === 0 && (
|
||||
<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-800">No sales channels matched your search.</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Try a different name or domain.</p>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<RegisterBusinessModal onClose={() => { setShowModal(false); loadBusinesses(); }} />
|
||||
<RegisterBusinessModal onClose={() => { setShowModal(false); load(); }} />
|
||||
)}
|
||||
{createdBusiness && <BusinessCreatedModal business={createdBusiness} onClose={() => setCreatedBusiness(null)} />}
|
||||
{deleteTarget && (
|
||||
|
|
|
|||
|
|
@ -102,18 +102,18 @@ const DEFAULT_EXPANDED_GROUPS = EVENT_GROUPS.reduce((acc, group) => {
|
|||
const EVENT_TEMPLATE_STATUS_CONFIG = {
|
||||
unselected: {
|
||||
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',
|
||||
},
|
||||
pending_whitelisting: {
|
||||
label: 'Pending Whitelisting',
|
||||
wrapper: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
dot: 'bg-amber-500',
|
||||
wrapper: 'border-gray-200 bg-white text-gray-700',
|
||||
dot: 'bg-white0',
|
||||
},
|
||||
whitelisted: {
|
||||
label: 'Published',
|
||||
wrapper: 'border-green-200 bg-green-50 text-green-700',
|
||||
dot: 'bg-green-500',
|
||||
wrapper: 'border-gray-200 bg-white text-gray-700',
|
||||
dot: 'bg-white0',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -507,7 +507,7 @@ export default function Events() {
|
|||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -519,7 +519,7 @@ export default function Events() {
|
|||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex flex-col gap-4 pb-5 mb-6 border-b border-gray-200">
|
||||
<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>
|
||||
</div>
|
||||
<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}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
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 && (
|
||||
<button
|
||||
|
|
@ -550,12 +550,12 @@ export default function Events() {
|
|||
</div>
|
||||
|
||||
<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
|
||||
</span>
|
||||
<button
|
||||
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'}
|
||||
</button>
|
||||
|
|
@ -564,32 +564,32 @@ export default function Events() {
|
|||
</div>
|
||||
|
||||
{!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>Set up and activate a <strong>cURL profile</strong> before generating templates.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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}
|
||||
<button onClick={() => setError('')} className="text-red-500 hover:text-red-700 font-bold">×</button>
|
||||
<button onClick={() => setError('')} className="text-gray-600 hover:text-gray-700 font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
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
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
|
|
@ -597,8 +597,8 @@ export default function Events() {
|
|||
)}
|
||||
|
||||
{groupedEvents.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-12 text-center shadow-sm">
|
||||
<p className="text-base font-semibold text-gray-900">No events match your search.</p>
|
||||
<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-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>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -607,26 +607,26 @@ export default function Events() {
|
|||
const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id];
|
||||
|
||||
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
|
||||
type="button"
|
||||
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={`mt-1 h-3 w-3 rounded-full shadow-sm ${
|
||||
group.id === 'fulfillment' ? 'bg-refresh-hover0' :
|
||||
<div className={`mt-1 h-3 w-3 rounded-full ${
|
||||
group.id === 'fulfillment' ? 'bg-gray-400' :
|
||||
group.id === 'delivery' ? 'bg-sky-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 === 'rto' ? 'bg-fuchsia-500' :
|
||||
'bg-gray-500'
|
||||
'bg-white0'
|
||||
}`} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-bold tracking-tight text-gray-900">{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">
|
||||
<h2 className="text-lg font-bold tracking-tight text-gray-800">{group.label}</h2>
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -643,7 +643,7 @@ export default function Events() {
|
|||
</button>
|
||||
|
||||
{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">
|
||||
{group.events.map((event) => {
|
||||
const state = genState[event.slug] || 'idle';
|
||||
|
|
@ -653,7 +653,7 @@ export default function Events() {
|
|||
const canViewTemplate = templateStatus !== 'unselected';
|
||||
|
||||
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 items-start gap-4">
|
||||
{event.isDefault ? (
|
||||
|
|
@ -663,14 +663,14 @@ export default function Events() {
|
|||
) : (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
|
|
@ -678,7 +678,7 @@ export default function Events() {
|
|||
<span
|
||||
title={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>
|
||||
|
|
@ -686,7 +686,7 @@ export default function Events() {
|
|||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
|
|
@ -694,10 +694,10 @@ export default function Events() {
|
|||
<button
|
||||
onClick={() => handleGenerate(event.slug)}
|
||||
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'
|
||||
? 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
: 'bg-refresh-hover border border-refresh-active text-primary-dark hover:bg-indigo-100'
|
||||
? 'bg-white border border-gray-300 text-gray-700 hover:bg-white'
|
||||
: 'bg-white border border-gray-200 text-primary-dark hover:bg-indigo-100'
|
||||
}`}
|
||||
>
|
||||
{state === 'loading' ? (
|
||||
|
|
@ -712,7 +712,7 @@ export default function Events() {
|
|||
</div>
|
||||
|
||||
{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>
|
||||
<div className="grid gap-4">
|
||||
{eventVariants.map((variant, index) => {
|
||||
|
|
@ -743,9 +743,9 @@ export default function Events() {
|
|||
return (
|
||||
<div
|
||||
key={variantKey}
|
||||
className={`rounded-lg border bg-white p-5 shadow-sm transition ${
|
||||
className={`rounded-lg border bg-white p-5 transition ${
|
||||
isSelectingThis
|
||||
? 'border-primary-blue ring-2 ring-indigo-100'
|
||||
? 'border-primary-blue '
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -753,26 +753,26 @@ export default function Events() {
|
|||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`text-[11px] font-bold px-2 py-1 rounded-full border ${
|
||||
isEdited
|
||||
? 'bg-amber-50 border-amber-200 text-amber-700'
|
||||
: 'bg-gray-50 border-gray-200 text-gray-600'
|
||||
? 'bg-white border-gray-200 text-gray-700'
|
||||
: 'bg-white border-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{isEdited ? 'Edited Draft' : 'Original Draft'}
|
||||
</span>
|
||||
|
||||
{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…
|
||||
</span>
|
||||
)}
|
||||
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -790,14 +790,14 @@ export default function Events() {
|
|||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleVariableMenuToggle(variantKey)}
|
||||
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
|
||||
</button>
|
||||
|
||||
{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="px-4 py-3 border-b border-gray-100 bg-gray-50">
|
||||
<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-2 border-b border-gray-100 bg-white">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider text-gray-500">Insert DLT Variable</p>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
|
|
@ -807,7 +807,7 @@ export default function Events() {
|
|||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
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-xs font-mono text-primary-dark">{option.token}</span>
|
||||
|
|
@ -831,10 +831,10 @@ export default function Events() {
|
|||
onSelect={(e) => trackTextareaSelection(variantKey, e.target)}
|
||||
onKeyUp={(e) => trackTextareaSelection(variantKey, e.target)}
|
||||
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
|
||||
? 'border-amber-200 bg-amber-50/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/40 focus:ring-amber-200 focus:border-amber-300'
|
||||
: '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">
|
||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-md border ${
|
||||
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'
|
||||
}`}>
|
||||
{currentText.length} / {MAX_SMS_LENGTH}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -855,7 +855,7 @@ export default function Events() {
|
|||
{isEdited && (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
|
@ -863,33 +863,33 @@ export default function Events() {
|
|||
</div>
|
||||
|
||||
{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-sm text-gray-600 font-mono leading-relaxed">{originalText}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>.
|
||||
Use only {DLT_VARIABLE_OPTIONS.map((option) => option.token).join(', ')}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -908,7 +908,7 @@ export default function Events() {
|
|||
<button
|
||||
onClick={() => handleSelect(event.slug, originalText, index)}
|
||||
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'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -195,31 +195,31 @@ export default function GlobalSms() {
|
|||
</div>
|
||||
|
||||
{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}
|
||||
<button onClick={() => setError('')} className="text-error-text hover:text-red-900 font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
{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}
|
||||
<button onClick={() => setSuccess('')} className="text-badge-text hover:opacity-75 font-bold">×</button>
|
||||
<button onClick={() => setSuccess('')} className="text-gray-700 hover:opacity-75 font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Profile Setup Review Block */}
|
||||
{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">
|
||||
<h3 className="font-bold text-text-primary text-lg">Active Setup: {activeProfile.name}</h3>
|
||||
{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 className="grid md:grid-cols-2 gap-6">
|
||||
<div className="grid md:grid-cols-2 gap-5">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-text-primary">Parsed Provider Data:</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
|
|
@ -243,7 +243,7 @@ export default function GlobalSms() {
|
|||
</div>
|
||||
|
||||
{!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>
|
||||
<form onSubmit={handleProviderSubmit} className="space-y-3">
|
||||
{missingFields.includes('providerName') && (
|
||||
|
|
@ -280,7 +280,7 @@ export default function GlobalSms() {
|
|||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</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>
|
||||
<button
|
||||
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 →
|
||||
</button>
|
||||
|
|
@ -310,17 +310,17 @@ export default function GlobalSms() {
|
|||
profiles.map(p => {
|
||||
const isActive = p.id === activeProfileId;
|
||||
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 items-center gap-3 mb-1">
|
||||
<h3 className="font-bold text-text-primary text-base truncate">{p.name}</h3>
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -334,7 +334,7 @@ export default function GlobalSms() {
|
|||
{!isActive && (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
|
@ -348,7 +348,7 @@ export default function GlobalSms() {
|
|||
{profiles.length > 1 && (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
|
@ -365,7 +365,7 @@ export default function GlobalSms() {
|
|||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h3 className="font-bold text-text-primary text-md">
|
||||
{editingId ? 'Edit Profile' : 'Add New Profile'}
|
||||
|
|
@ -376,7 +376,7 @@ export default function GlobalSms() {
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="p-5">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<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}
|
||||
onChange={e => setFormName(e.target.value)}
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -395,7 +395,7 @@ export default function GlobalSms() {
|
|||
value={formCurl}
|
||||
onChange={e => setFormCurl(e.target.value)}
|
||||
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
|
||||
spellCheck="false"
|
||||
/>
|
||||
|
|
@ -415,7 +415,7 @@ export default function GlobalSms() {
|
|||
<button
|
||||
type="submit"
|
||||
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')}
|
||||
</button>
|
||||
|
|
@ -424,7 +424,7 @@ export default function GlobalSms() {
|
|||
type="button"
|
||||
onClick={handleAddClick}
|
||||
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
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export default function Providers() {
|
|||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ export default function Providers() {
|
|||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<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>
|
||||
{activeProfile && (
|
||||
<p className="text-xs text-gray-500 mt-2 font-semibold uppercase tracking-wide">
|
||||
|
|
@ -91,20 +91,20 @@ export default function Providers() {
|
|||
</div>
|
||||
|
||||
{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}
|
||||
<button onClick={() => setError('')} className="text-red-500 hover:text-red-700 font-bold">×</button>
|
||||
<button onClick={() => setError('')} className="text-gray-600 hover:text-gray-700 font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
{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}
|
||||
<button onClick={() => setSuccess('')} className="text-green-500 hover:text-green-700 font-bold">×</button>
|
||||
<button onClick={() => setSuccess('')} className="text-gray-600 hover:text-gray-700 font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSave} className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="p-6 space-y-6">
|
||||
<form onSubmit={handleSave} className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="p-5 space-y-6">
|
||||
<div>
|
||||
<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>}
|
||||
|
|
@ -113,12 +113,12 @@ export default function Providers() {
|
|||
type="text"
|
||||
value={form.providerName}
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
<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>}
|
||||
|
|
@ -128,7 +128,7 @@ export default function Providers() {
|
|||
value={form.senderId}
|
||||
onChange={e => handleChange('senderId', e.target.value.toUpperCase())}
|
||||
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"
|
||||
/>
|
||||
<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"
|
||||
value={form.dltEntityId}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -154,18 +154,18 @@ export default function Providers() {
|
|||
type="password"
|
||||
value={form.authKey}
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2 font-medium">Used as the Authorization header in your SMS requests.</p>
|
||||
</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
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import TestSmsModal from '../components/TestSmsModal';
|
|||
|
||||
const STATUS_CONFIG = {
|
||||
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' },
|
||||
whitelisted: { label: 'Published', bg: 'bg-badge-bg', text: 'text-badge-text', border: 'border-badge-border' },
|
||||
pending_whitelisting: { label: 'Pending Whitelisting', bg: 'bg-white', text: 'text-gray-700', border: 'border-gray-200' },
|
||||
whitelisted: { label: 'Published', bg: 'bg-white', text: 'text-gray-700', border: 'border-gray-200' },
|
||||
};
|
||||
|
||||
export default function Templates() {
|
||||
|
|
@ -103,7 +103,7 @@ export default function Templates() {
|
|||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -111,12 +111,12 @@ export default function Templates() {
|
|||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
<button onClick={() => setError('')} className="text-error-text hover:text-red-900 font-bold">×</button>
|
||||
</div>
|
||||
|
|
@ -146,7 +146,7 @@ export default function Templates() {
|
|||
</div>
|
||||
|
||||
{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">
|
||||
<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>
|
||||
|
|
@ -186,15 +186,15 @@ export default function Templates() {
|
|||
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
|
||||
? 'border-primary-blue ring-2 ring-indigo-200 animate-pulse'
|
||||
? 'border-primary-blue animate-pulse'
|
||||
: '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>
|
||||
<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, ' ')}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 font-mono mt-0.5">{tmpl.eventSlug}</p>
|
||||
|
|
@ -204,16 +204,16 @@ export default function Templates() {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Bound cURL Profile</label>
|
||||
{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="text-gray-400 font-mono text-xs">{boundProfile.id}</span>
|
||||
</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}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -221,7 +221,7 @@ export default function Templates() {
|
|||
|
||||
<div>
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -229,7 +229,7 @@ export default function Templates() {
|
|||
{tmpl.templateId && (
|
||||
<div>
|
||||
<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}
|
||||
</p>
|
||||
</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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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="text-gray-400">→</span>
|
||||
<span className="font-medium text-gray-700">{val}</span>
|
||||
|
|
@ -254,7 +254,7 @@ export default function Templates() {
|
|||
{!isBoundProfileMissing && tmpl.status === 'pending_whitelisting' && (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
|
@ -262,7 +262,7 @@ export default function Templates() {
|
|||
{!isBoundProfileMissing && tmpl.status === 'whitelisted' && (
|
||||
<button
|
||||
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>
|
||||
Test SMS
|
||||
|
|
|
|||
|
|
@ -116,8 +116,19 @@ export function normalizeChannelsPayload(data) {
|
|||
return channels.map(normalizeChannel);
|
||||
}
|
||||
|
||||
export function getApplicationId(entity) {
|
||||
return (
|
||||
entity?.applicationId
|
||||
|| entity?.salesChannelId
|
||||
|| entity?.application_id
|
||||
|| entity?.id
|
||||
|| entity?._id
|
||||
|| ''
|
||||
);
|
||||
}
|
||||
|
||||
export function getChannelId(channel) {
|
||||
return channel?.salesChannelId || channel?.id || channel?._id || '';
|
||||
return getApplicationId(channel);
|
||||
}
|
||||
|
||||
export function isChannelActive(channel) {
|
||||
|
|
|
|||
|
|
@ -1,99 +1,12 @@
|
|||
import { getRuntimeCompanyId, getRuntimeExtensionId } from './runtimeContext';
|
||||
import {
|
||||
getChannelId,
|
||||
isChannelActive,
|
||||
normalizeChannelsPayload,
|
||||
} from './businessProfile';
|
||||
|
||||
const FYND_PORTAL_API_BASE = 'https://api.fynd.com';
|
||||
|
||||
function normalizeText(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeWebsiteUrl(value) {
|
||||
const rawValue = normalizeText(value);
|
||||
if (!rawValue) return '';
|
||||
|
||||
const candidate = /^https?:\/\//i.test(rawValue) ? rawValue : `https://${rawValue}`;
|
||||
|
||||
try {
|
||||
return new URL(candidate).toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(name) {
|
||||
if (typeof document === 'undefined') return '';
|
||||
|
||||
const cookies = `; ${document.cookie || ''}`;
|
||||
const parts = cookies.split(`; ${name}=`);
|
||||
if (parts.length < 2) return '';
|
||||
return parts.pop().split(';').shift() || '';
|
||||
}
|
||||
|
||||
function buildPortalRequestUrl(companyId, extensionId) {
|
||||
const search = new URLSearchParams({
|
||||
company_id: companyId,
|
||||
page_no: '1',
|
||||
page_size: '100',
|
||||
query: JSON.stringify({ is_active: true }),
|
||||
});
|
||||
|
||||
if (extensionId) {
|
||||
search.set('extension_id', extensionId);
|
||||
}
|
||||
|
||||
return `${FYND_PORTAL_API_BASE}/service/portal/configuration/v1.0/company/${encodeURIComponent(companyId)}/application?${search.toString()}`;
|
||||
}
|
||||
|
||||
function buildPortalHeaders() {
|
||||
const headers = {
|
||||
accept: 'application/json, text/plain, */*',
|
||||
};
|
||||
|
||||
const token = normalizeText(readCookie('token'));
|
||||
if (token) {
|
||||
headers.authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function withDerivedWebsiteUrl(channel) {
|
||||
const explicitWebsiteUrl = normalizeWebsiteUrl(
|
||||
channel?.websiteUrl || channel?.domain || channel?.url
|
||||
);
|
||||
|
||||
return explicitWebsiteUrl
|
||||
? { ...channel, websiteUrl: explicitWebsiteUrl }
|
||||
: channel;
|
||||
}
|
||||
import apiClient from '../api/client';
|
||||
import { normalizeChannelsPayload } from './businessProfile';
|
||||
|
||||
export async function fetchActiveSalesChannels() {
|
||||
const companyId = getRuntimeCompanyId();
|
||||
const extensionId = getRuntimeExtensionId();
|
||||
|
||||
if (!companyId) {
|
||||
throw new Error('Company ID is unavailable for fetching sales channels.');
|
||||
}
|
||||
|
||||
const response = await fetch(buildPortalRequestUrl(companyId, extensionId), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: buildPortalHeaders(),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = normalizeText(payload?.message || payload?.error)
|
||||
|| `Sales channels could not be fetched (${response.status}).`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return normalizeChannelsPayload(payload)
|
||||
.map(withDerivedWebsiteUrl)
|
||||
.filter((channel) => isChannelActive(channel) && getChannelId(channel));
|
||||
const response = await apiClient.get('/api/platform/sales-channels');
|
||||
return normalizeChannelsPayload(
|
||||
response?.data?.salesChannels
|
||||
|| response?.data?.channels
|
||||
|| response?.data?.items
|
||||
|| []
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ require('dotenv').config();
|
|||
|
||||
const { setupFdk } = require('@gofynd/fdk-extension-javascript/express');
|
||||
const { MemoryStorage } = require('@gofynd/fdk-extension-javascript/express/storage');
|
||||
const { createFdkStorage } = require('./postgresFdkStorage');
|
||||
|
||||
function normalizeEnvText(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
|
|
@ -28,6 +29,13 @@ function createFdkExtension() {
|
|||
}
|
||||
|
||||
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({
|
||||
api_key: apiKey,
|
||||
api_secret: apiSecret,
|
||||
|
|
@ -41,7 +49,7 @@ function createFdkExtension() {
|
|||
console.log(`[FDK] uninstall callback received for company ${companyId || 'unknown'}`);
|
||||
},
|
||||
},
|
||||
storage: new MemoryStorage('sms_extension_'),
|
||||
storage,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[FDK] Failed to initialize FDK: ${error.message}`);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
|
||||
const businessesRoutes = require('./routes/businesses');
|
||||
const platformRoutes = require('./routes/platform');
|
||||
const { fdkExtension, isFdkConfigured } = require('./fdk');
|
||||
|
||||
const app = express();
|
||||
|
|
@ -29,6 +30,9 @@ app.get('/api/health', (req, res) => res.json({
|
|||
|
||||
if (fdkExtension) {
|
||||
app.use(fdkExtension.fdkHandler);
|
||||
app.use('/api/platform', fdkExtension.platformApiRoutes, platformRoutes);
|
||||
} else {
|
||||
app.use('/api/platform', platformRoutes);
|
||||
}
|
||||
|
||||
// Routes
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"openai": "^4.28.0",
|
||||
"pg": "^8.16.3",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -1668,6 +1669,96 @@
|
|||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"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": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
|
|
@ -1681,6 +1772,45 @@
|
|||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
|
|
@ -2007,6 +2137,15 @@
|
|||
"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": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"openai": "^4.28.0",
|
||||
"pg": "^8.16.3",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user