First commit
This commit is contained in:
commit
25ba4c1937
|
|
@ -0,0 +1,45 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
server/node_modules/
|
||||
client/node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
dist-ssr/
|
||||
build/
|
||||
coverage/
|
||||
.vite/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
*.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
*.out
|
||||
*.err
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
|
||||
# planning files
|
||||
*.md
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -0,0 +1,184 @@
|
|||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { BusinessProvider, useBusiness } from './context/BusinessContext';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Businesses from './pages/Businesses';
|
||||
import Providers from './pages/Providers';
|
||||
import GlobalSms from './pages/GlobalSms';
|
||||
import Events from './pages/Events';
|
||||
import Templates from './pages/Templates';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
function SubLayout({ children }) {
|
||||
const { activeBusinessId } = useBusiness();
|
||||
return (
|
||||
<div className="flex min-h-screen bg-page-bg">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-60 flex flex-col">
|
||||
<header className="h-16 border-b border-border-main bg-white flex items-center justify-end px-8 z-10 shrink-0">
|
||||
<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"
|
||||
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">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Guard: redirect to / if no active business in session
|
||||
// Also enforce cURL-first: redirect to global-sms if no cURL is saved yet.
|
||||
function BusinessGuard({ children, isGlobalSmsRoute }) {
|
||||
const { activeBusinessId, loading, hasGlobalSms } = useBusiness();
|
||||
const location = useLocation();
|
||||
|
||||
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-indigo-200 border-t-primary-blue rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeBusinessId) {
|
||||
return <Navigate to="/" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (!hasGlobalSms && !isGlobalSmsRoute && !location.pathname.endsWith('/settings')) {
|
||||
// Only allow global SMS page if cURL constraint is not met yet
|
||||
// Optionally allow settings, but strictly planning says "must go to Global SMS first".
|
||||
// We enforce only global SMS by redirecting other pages.
|
||||
return <Navigate to={`/${activeBusinessId}/global-sms`} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BusinessProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Businesses />} />
|
||||
<Route path="/:businessId/settings" element={
|
||||
<BusinessGuard><SubLayout><Providers /></SubLayout></BusinessGuard>
|
||||
} />
|
||||
<Route path="/:businessId/global-sms" element={
|
||||
<BusinessGuard isGlobalSmsRoute><SubLayout><GlobalSms /></SubLayout></BusinessGuard>
|
||||
} />
|
||||
<Route path="/:businessId/events" element={
|
||||
<BusinessGuard><SubLayout><Events /></SubLayout></BusinessGuard>
|
||||
} />
|
||||
<Route path="/:businessId/templates" element={
|
||||
<BusinessGuard><SubLayout><Templates /></SubLayout></BusinessGuard>
|
||||
} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</BusinessProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
export default apiClient;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -0,0 +1,56 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
export default function ImagePicker({ slug, currentImage, onSelect }) {
|
||||
const [images, setImages] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadImages() {
|
||||
try {
|
||||
const res = await apiClient.get('/api/templates/images');
|
||||
setImages(res.data.images || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load images', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadImages();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-sm text-gray-400">Loading brand images…</div>;
|
||||
}
|
||||
|
||||
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="grid grid-cols-3 gap-3">
|
||||
{images.map((img, i) => {
|
||||
const isSelected = currentImage === img.url;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelect(img.url)}
|
||||
className={`relative rounded-lg overflow-hidden border-2 aspect-video transition-all ${
|
||||
isSelected
|
||||
? 'border-indigo-600 ring-2 ring-indigo-600 ring-opacity-50 shadow-md'
|
||||
: 'border-transparent hover:border-gray-300 opacity-80 hover:opacity-100 shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<img src={img.url} alt={`brand-pic-${i}`} className="w-full h-full object-cover" />
|
||||
<div className={`absolute inset-0 bg-indigo-600/20 transition-opacity ${isSelected ? 'opacity-100' : 'opacity-0'}`} />
|
||||
{isSelected && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 bg-indigo-600 rounded-full flex items-center justify-center shadow-lg 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>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { useState } from 'react';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
export default function RegisterBusinessModal({ onClose }) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [status, setStatus] = useState('idle'); // idle | loading | success | error
|
||||
const [brandName, setBrandName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!url.trim()) return;
|
||||
setStatus('loading');
|
||||
setError('');
|
||||
try {
|
||||
const res = await apiClient.post('/api/businesses', { websiteUrl: url.trim() });
|
||||
setBrandName(res.data.brandName);
|
||||
setStatus('success');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Something went wrong. Please try again.');
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
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-xl p-8 w-full max-w-md shadow-xl">
|
||||
|
||||
{/* Success */}
|
||||
{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>
|
||||
<p className="text-gray-500 text-sm mb-1 font-medium">Brand detected:</p>
|
||||
<p className="text-indigo-600 font-bold text-lg mb-6 tracking-tight">{brandName}</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 shadow-sm text-white font-medium transition"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{(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>
|
||||
<p className="text-gray-500 text-sm leading-relaxed">
|
||||
Enter your website URL. We'll scrape your site and extract brand context to generate TRAI-compliant SMS templates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5 tracking-wide">Website URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
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-indigo-600 focus:border-transparent transition disabled:opacity-50 text-sm shadow-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>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'loading' || !url.trim()}
|
||||
className="flex-[1.2] py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium transition shadow-sm disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Analysing…</>
|
||||
) : 'Add Business'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === 'loading' && (
|
||||
<p className="text-xs text-gray-500 font-medium text-center pt-2">
|
||||
Scraping your site and extracting brand context. This may take 20–30 seconds.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useBusiness } from '../context/BusinessContext';
|
||||
|
||||
const SVG_ICONS = {
|
||||
providers: (
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
),
|
||||
globalSms: (
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
events: (
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
templates: (
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" 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>
|
||||
),
|
||||
};
|
||||
|
||||
export default function Sidebar() {
|
||||
const { activeBusiness, activeBusinessId, clearBusiness } = useBusiness();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navItems = [
|
||||
{ id: 'globalSms', to: `/${activeBusinessId}/global-sms`, label: 'Global SMS cURL' },
|
||||
{ id: 'events', to: `/${activeBusinessId}/events`, label: 'Events' },
|
||||
{ id: 'templates', to: `/${activeBusinessId}/templates`, label: 'Templates' },
|
||||
];
|
||||
|
||||
function handleSwitch() {
|
||||
clearBusiness();
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="fixed top-0 left-0 h-screen w-60 bg-white border-r border-gray-200 flex flex-col z-10">
|
||||
{/* Business info + switch */}
|
||||
<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"
|
||||
>
|
||||
<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-indigo-600 flex items-center justify-center text-sm font-bold text-white shrink-0 shadow-sm">
|
||||
{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-xs text-gray-400 truncate font-medium">{activeBusiness.domain}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 pt-5 space-y-1">
|
||||
{navItems.map(({ id, to, label }) => (
|
||||
<NavLink
|
||||
key={id}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-colors duration-150 ${
|
||||
isActive
|
||||
? 'bg-refresh-hover text-primary-blue'
|
||||
: 'text-text-muted hover:text-text-primary hover:bg-row-hover'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{SVG_ICONS[id]}
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="px-5 py-4 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-500 font-medium tracking-wide">TRAI-compliant SMS</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { useState } from 'react';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
export default function TestSmsModal({ businessId, template, onClose }) {
|
||||
const [toNumber, setToNumber] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSend(e) {
|
||||
e.preventDefault();
|
||||
if (!toNumber.trim()) return;
|
||||
setSending(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
`/api/businesses/${businessId}/templates/${template.eventSlug}/test`,
|
||||
{ toNumber: toNumber.trim() }
|
||||
);
|
||||
setResult(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || err.response?.data?.details || 'Failed to send test SMS');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
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-xl p-8 w-full max-w-md shadow-xl">
|
||||
<div className="w-12 h-12 rounded-full bg-green-50 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>
|
||||
<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>
|
||||
|
||||
{!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">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
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-indigo-600 text-sm"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1.5 font-medium">Include country code without +. Not stored.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
{sending ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Sending…</> : 'Send Test SMS'}
|
||||
</button>
|
||||
</div>
|
||||
</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'}`}>
|
||||
{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">
|
||||
{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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
export default function WhitelistModal({ businessId, template, onClose, onSuccess }) {
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
const [toNumber, setToNumber] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [providers, setProviders] = useState(null);
|
||||
const [form, setForm] = useState({ providerName: '', senderId: '', dltEntityId: '' });
|
||||
const [loadingProviders, setLoadingProviders] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchProviders() {
|
||||
try {
|
||||
const res = await apiClient.get(`/api/businesses/${businessId}/providers`);
|
||||
setProviders(res.data || {});
|
||||
setForm({
|
||||
providerName: res.data?.providerName || '',
|
||||
senderId: res.data?.senderId || '',
|
||||
dltEntityId: res.data?.dltEntityId || ''
|
||||
});
|
||||
} catch {
|
||||
setProviders({});
|
||||
} finally {
|
||||
setLoadingProviders(false);
|
||||
}
|
||||
}
|
||||
fetchProviders();
|
||||
}, [businessId]);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!templateId.trim() || !toNumber.trim()) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await apiClient.post(`/api/businesses/${businessId}/templates/${template.eventSlug}/publish`, {
|
||||
templateId: templateId.trim(),
|
||||
toNumber: toNumber.trim(),
|
||||
providerName: form.providerName,
|
||||
senderId: form.senderId.toUpperCase(),
|
||||
dltEntityId: form.dltEntityId
|
||||
});
|
||||
onSuccess(template.eventSlug, templateId.trim());
|
||||
} catch (err) {
|
||||
if (err.response?.data?.missingFields) {
|
||||
setError(`Missing provider fields: ${err.response.data.missingFields.join(', ')}`);
|
||||
} else {
|
||||
setError(err.response?.data?.error || 'Failed to publish template');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const missingName = !providers?.providerName;
|
||||
const missingSender = !providers?.senderId;
|
||||
const missingDlt = !providers?.dltEntityId;
|
||||
const hasMissingProviders = missingName || missingSender || missingDlt;
|
||||
|
||||
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-xl p-8 w-full max-w-md shadow-xl my-auto">
|
||||
<div className="w-12 h-12 rounded-full bg-orange-bg flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-xl">✅</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-text-primary text-center mb-1">Publish Template</h3>
|
||||
<p className="text-sm text-text-muted text-center mb-1">
|
||||
Provide your DLT details and a test number to publish:
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-text-primary text-center mb-6 capitalize">
|
||||
{template.eventLabel || template.eventSlug.replace(/_/g, ' ')}
|
||||
</p>
|
||||
|
||||
{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">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingProviders ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<span className="w-6 h-6 border-2 border-spinner-track border-t-primary-blue rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">DLT Template ID</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Destination Phone Number</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">Number to send the initial request on publish</p>
|
||||
</div>
|
||||
|
||||
{hasMissingProviders && (
|
||||
<div className="pt-2 pb-2">
|
||||
<p className="text-xs text-error-text font-bold mb-3">You MUST provide missing provider details before publishing.</p>
|
||||
<div className="space-y-3 p-4 bg-delayed-bg border border-delayed-border rounded-lg">
|
||||
{missingName && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-text-primary mb-1">Provider Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.providerName}
|
||||
onChange={e => setForm({ ...form, providerName: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded border border-border-main bg-surface-white text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{missingSender && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-text-primary mb-1">Sender ID (6 Chars)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.senderId}
|
||||
onChange={e => setForm({ ...form, senderId: e.target.value.toUpperCase() })}
|
||||
maxLength={6}
|
||||
className="w-full px-3 py-2 rounded border border-border-main bg-surface-white text-sm font-mono uppercase"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{missingDlt && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-text-primary mb-1">DLT Entity ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.dltEntityId}
|
||||
onChange={e => setForm({ ...form, dltEntityId: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded border border-border-main bg-surface-white text-sm font-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !templateId.trim() || !toNumber.trim() || (hasMissingProviders && (!form.providerName || !form.senderId || !form.dltEntityId))}
|
||||
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"
|
||||
>
|
||||
{saving ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Publishing…</> : 'Publish'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
const BrandContext = createContext(null);
|
||||
|
||||
export function BrandProvider({ children }) {
|
||||
const [brand, setBrand] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchBrand = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiClient.get('/api/brand');
|
||||
setBrand(res.data);
|
||||
} catch {
|
||||
setBrand(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchBrand(); }, [fetchBrand]);
|
||||
|
||||
return (
|
||||
<BrandContext.Provider value={{ brand, loading, refetch: fetchBrand }}>
|
||||
{children}
|
||||
</BrandContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBrand() {
|
||||
return useContext(BrandContext);
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
const BusinessContext = createContext(null);
|
||||
|
||||
const SESSION_KEY = 'sms_active_business';
|
||||
|
||||
export function BusinessProvider({ children }) {
|
||||
const [activeBusiness, setActiveBusinessState] = useState(null);
|
||||
const [hasGlobalSms, setHasGlobalSms] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// On mount: rehydrate from sessionStorage and refresh from API
|
||||
useEffect(() => {
|
||||
async function rehydrate() {
|
||||
const stored = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!stored) { setLoading(false); return; }
|
||||
try {
|
||||
const { businessId } = JSON.parse(stored);
|
||||
const [bizRes, smsRes] = await Promise.all([
|
||||
apiClient.get(`/api/businesses/${businessId}`),
|
||||
apiClient.get(`/api/businesses/${businessId}/global-sms/active`).catch(() => ({ data: {} }))
|
||||
]);
|
||||
setActiveBusinessState(bizRes.data);
|
||||
setHasGlobalSms(!!smsRes.data?.activeProfile);
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ businessId }));
|
||||
} catch {
|
||||
// Business no longer exists — clear stale session
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
setActiveBusinessState(null);
|
||||
setHasGlobalSms(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
rehydrate();
|
||||
}, []);
|
||||
|
||||
const setActiveBusiness = useCallback(async (business) => {
|
||||
setActiveBusinessState(business);
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ businessId: business.businessId }));
|
||||
try {
|
||||
const smsRes = await apiClient.get(`/api/businesses/${business.businessId}/global-sms/active`);
|
||||
setHasGlobalSms(!!smsRes.data?.activeProfile);
|
||||
} catch {
|
||||
setHasGlobalSms(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearBusiness = useCallback(() => {
|
||||
setActiveBusinessState(null);
|
||||
setHasGlobalSms(false);
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
}, []);
|
||||
|
||||
const activeBusinessId = activeBusiness?.businessId || null;
|
||||
|
||||
return (
|
||||
<BusinessContext.Provider value={{
|
||||
activeBusiness, activeBusinessId, setActiveBusiness, clearBusiness, loading, hasGlobalSms, setHasGlobalSms
|
||||
}}>
|
||||
{children}
|
||||
</BusinessContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBusiness() {
|
||||
return useContext(BusinessContext);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary-blue: #4F5BD5;
|
||||
--color-primary-dark: #2F3DB9;
|
||||
--color-link-blue: #5B72D7;
|
||||
--color-page-bg: #F5F6F8;
|
||||
--color-surface-white: #FFFFFF;
|
||||
--color-table-header: #F4F5F7;
|
||||
--color-pagination-bg: #F8F9FB;
|
||||
--color-refresh-hover: #F4F6FF;
|
||||
--color-refresh-active: #E9EDFF;
|
||||
--color-row-hover: #EEF0F8;
|
||||
--color-text-primary: #3F434C;
|
||||
--color-text-muted: #8B9098;
|
||||
--color-header-text: #4B4F57;
|
||||
--color-channel-name: #61656D;
|
||||
--color-border-main: #E2E5EA;
|
||||
--color-border-soft: #ECEFF3;
|
||||
--color-item-border: #ECEEF2;
|
||||
--color-badge-bg: #F0FAEF;
|
||||
--color-badge-text: #5FA05A;
|
||||
--color-badge-border: #9FCF9B;
|
||||
--color-delayed-bg: #FFF1F0;
|
||||
--color-delayed-text: #D94F4F;
|
||||
--color-delayed-border: #F5A5A5;
|
||||
--color-tags-bg: #FFF8F3;
|
||||
--color-tags-text: #E58D4F;
|
||||
--color-tags-border: #F2B17F;
|
||||
--color-error-text: #C62828;
|
||||
--color-sla-green: #4CAF50;
|
||||
--color-sla-grey: #BDBDBD;
|
||||
--color-spinner-track: #D8DEFE;
|
||||
--color-placeholder-bg: #E0E0E0;
|
||||
--color-empty-bg: #FAFAFA;
|
||||
}
|
||||
|
||||
:root {
|
||||
--sidebar-width: 240px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--color-page-bg, #f9fafb);
|
||||
color: var(--color-text-primary, #111827);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-border-soft, #f3f4f6);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-main, #d1d5db);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useBrand } from '../context/BrandContext';
|
||||
import RegisterBusinessModal from '../components/RegisterBusinessModal';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
const NAV_CARDS = [
|
||||
{ to: '/events', label: 'Events', icon: '⚡', desc: 'Manage order events and generate templates' },
|
||||
{ to: '/templates', label: 'Templates', icon: '📄', desc: 'Review and configure approved templates' },
|
||||
{ to: '/providers', label: 'Providers', icon: '🏢', desc: 'Save your SMS provider DLT details' },
|
||||
];
|
||||
|
||||
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-xl p-8 w-full max-w-md shadow-xl">
|
||||
<div className="w-12 h-12 rounded-full bg-red-50 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>
|
||||
<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.
|
||||
</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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
{deleting ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Deleting…</> : 'Yes, Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Brand() {
|
||||
const { brand, loading, refetch } = useBrand();
|
||||
const navigate = useNavigate();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
setDeleteError('');
|
||||
try {
|
||||
await apiClient.delete('/api/brand');
|
||||
setShowDeleteConfirm(false);
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
setDeleteError(err.response?.data?.error || 'Failed to delete brand');
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
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-indigo-200 border-t-indigo-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// — WELCOME SCREEN —
|
||||
if (!brand) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center max-w-lg px-8">
|
||||
<div className="w-16 h-16 rounded-2xl bg-indigo-600 flex items-center justify-center mx-auto mb-6 text-2xl font-bold text-white shadow-lg shadow-indigo-600/20">
|
||||
S
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 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-indigo-600 hover:bg-indigo-700 text-white font-medium shadow-sm transition-all focus:ring-2 focus:ring-offset-2 focus:ring-indigo-600"
|
||||
>
|
||||
Register Your Business
|
||||
</button>
|
||||
</div>
|
||||
{showModal && <RegisterBusinessModal onClose={() => { setShowModal(false); refetch(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// — BRAND DETAIL PAGE —
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
|
||||
{/* Brand header card */}
|
||||
<div className="rounded-xl bg-white border border-gray-200 shadow-sm p-8 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>
|
||||
<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-indigo-50 border border-indigo-100 text-indigo-700 font-medium capitalize">
|
||||
{brand.tone}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 font-medium tracking-wide">
|
||||
Registered {new Date(brand.scrapedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
Delete Brand
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Taglines */}
|
||||
{brand.taglines?.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-400 font-semibold uppercase tracking-wider mb-3">Taglines</p>
|
||||
<div className="space-y-1.5">
|
||||
{brand.taglines.map((t, i) => (
|
||||
<p key={i} className="text-gray-700 text-sm">"{t}"</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Colors */}
|
||||
{brand.colors?.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<p className="text-xs text-gray-400 font-semibold uppercase tracking-wider mb-3">Brand Colors</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{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"
|
||||
style={{ backgroundColor: c }}
|
||||
title={c}
|
||||
/>
|
||||
<span className="text-xs text-gray-500 font-mono">{c}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brand images */}
|
||||
{brand.relevantImagePaths?.length > 0 && (
|
||||
<div className="rounded-xl bg-white border border-gray-200 shadow-sm p-6 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">
|
||||
<img
|
||||
src={url}
|
||||
alt={`brand image ${i + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={e => { e.target.style.opacity = '0.3'; }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gray-900/80 opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-2">
|
||||
<p className="text-xs text-white break-all leading-tight line-clamp-3 font-medium">{url}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{NAV_CARDS.map(card => (
|
||||
<Link
|
||||
key={card.to}
|
||||
to={card.to}
|
||||
className="rounded-xl bg-white border border-gray-200 shadow-sm p-5 hover:border-indigo-300 hover:ring-1 hover:ring-indigo-300 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="text-sm text-gray-500 leading-relaxed">{card.desc}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{deleteError && (
|
||||
<p className="mt-4 text-sm font-medium text-red-600 text-center">{deleteError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<DeleteConfirmModal
|
||||
brandName={brand.brandName}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
onConfirm={handleDelete}
|
||||
deleting={deleting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import apiClient from '../api/client';
|
||||
import { useBusiness } from '../context/BusinessContext';
|
||||
import RegisterBusinessModal from '../components/RegisterBusinessModal';
|
||||
|
||||
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-xl p-8 w-full max-w-md shadow-xl">
|
||||
<div className="w-12 h-12 rounded-full bg-red-50 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>
|
||||
<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.
|
||||
</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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
{deleting ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Deleting…</> : 'Yes, Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Businesses() {
|
||||
const navigate = useNavigate();
|
||||
const { setActiveBusiness } = useBusiness();
|
||||
const [businesses, setBusinesses] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get('/api/businesses');
|
||||
setBusinesses(res.data.businesses || []);
|
||||
} catch {
|
||||
setError('Failed to load businesses');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
function handleSelect(biz) {
|
||||
setActiveBusiness(biz);
|
||||
navigate(`/${biz.businessId}/global-sms`);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await apiClient.delete(`/api/businesses/${deleteTarget.businessId}`);
|
||||
setDeleteTarget(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to delete business');
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
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-indigo-200 border-t-indigo-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── NO BUSINESSES YET ──────────────────────────────────────────────────────
|
||||
if (businesses.length === 0 && !showModal) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center max-w-lg px-8">
|
||||
<div className="w-16 h-16 rounded-2xl bg-indigo-600 flex items-center justify-center mx-auto mb-6 text-2xl font-bold text-white shadow-lg shadow-indigo-600/20">
|
||||
S
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 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. Add your first business to get started.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-8 py-3 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium shadow-sm transition-all focus:ring-2 focus:ring-offset-2 focus:ring-indigo-600"
|
||||
>
|
||||
Add Your First Business
|
||||
</button>
|
||||
</div>
|
||||
{showModal && <RegisterBusinessModal onClose={() => { setShowModal(false); load(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BUSINESS LIST ──────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Your Businesses</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Select a business to manage its SMS templates.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold shadow-sm 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">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="text-red-500 hover:text-red-700 font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{businesses.map(biz => (
|
||||
<div
|
||||
key={biz.businessId}
|
||||
className="group rounded-xl bg-white border border-gray-200 shadow-sm hover:border-indigo-300 hover:ring-1 hover:ring-indigo-300 transition-all overflow-hidden"
|
||||
>
|
||||
<button
|
||||
className="w-full text-left p-6"
|
||||
onClick={() => handleSelect(biz)}
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-indigo-600 flex items-center justify-center text-base font-bold text-white shrink-0 shadow-sm">
|
||||
{biz.brandName?.[0]?.toUpperCase() || 'B'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-gray-900 truncate">{biz.brandName}</p>
|
||||
<p className="text-xs text-gray-500 font-medium truncate">{biz.domain}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 font-medium">
|
||||
Added {new Date(biz.createdAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</p>
|
||||
</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-indigo-600 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>
|
||||
|
||||
{showModal && <RegisterBusinessModal onClose={() => { setShowModal(false); load(); }} />}
|
||||
{deleteTarget && (
|
||||
<DeleteConfirmModal
|
||||
businessName={deleteTarget.brandName}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
deleting={deleting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
export default function Events() {
|
||||
const { businessId } = useParams();
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [addingEvent, setAddingEvent] = useState(false);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [genState, setGenState] = useState({});
|
||||
const [variants, setVariants] = useState({});
|
||||
const [selectingSlug, setSelectingSlug] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [readyToGenerate, setReadyToGenerate] = useState(false);
|
||||
|
||||
async function loadEvents() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [eventsRes, providersRes, globalSmsRes] = await Promise.all([
|
||||
apiClient.get(`/api/businesses/${businessId}/events`),
|
||||
apiClient.get(`/api/businesses/${businessId}/providers`).catch(() => ({ data: {} })),
|
||||
apiClient.get(`/api/businesses/${businessId}/global-sms`).catch(() => ({ data: {} })),
|
||||
]);
|
||||
setEvents(eventsRes.data.events || []);
|
||||
const hasProviders = !!providersRes.data?.senderId;
|
||||
const hasGlobalSms = !!globalSmsRes.data?.rawCurl;
|
||||
setReadyToGenerate(hasProviders && hasGlobalSms);
|
||||
} catch {
|
||||
setError('Failed to load events');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadEvents(); }, [businessId]);
|
||||
|
||||
async function handleAddEvent(e) {
|
||||
e.preventDefault();
|
||||
if (!newLabel.trim()) return;
|
||||
setAddingEvent(true);
|
||||
setError('');
|
||||
try {
|
||||
await apiClient.post(`/api/businesses/${businessId}/events`, { label: newLabel.trim() });
|
||||
setNewLabel('');
|
||||
setShowAddForm(false);
|
||||
await loadEvents();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to add event');
|
||||
} finally {
|
||||
setAddingEvent(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(slug) {
|
||||
try {
|
||||
await apiClient.delete(`/api/businesses/${businessId}/events/${slug}`);
|
||||
await loadEvents();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to delete event');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate(slug) {
|
||||
if (!readyToGenerate) {
|
||||
setError('Configure Provider and Global SMS cURL before generating templates.');
|
||||
return;
|
||||
}
|
||||
setGenState(s => ({ ...s, [slug]: 'loading' }));
|
||||
setError('');
|
||||
try {
|
||||
const res = await apiClient.post(`/api/businesses/${businessId}/events/${slug}/generate`);
|
||||
setVariants(v => ({ ...v, [slug]: res.data.variants }));
|
||||
setGenState(s => ({ ...s, [slug]: 'done' }));
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Generation failed');
|
||||
setGenState(s => ({ ...s, [slug]: 'error' }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelect(slug, variant) {
|
||||
setSelectingSlug(slug);
|
||||
setError('');
|
||||
try {
|
||||
await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant });
|
||||
// Clear variants display after selection
|
||||
setVariants(v => ({ ...v, [slug]: [] }));
|
||||
setGenState(s => ({ ...s, [slug]: 'selected' }));
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to select template');
|
||||
} finally {
|
||||
setSelectingSlug(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-2 border-indigo-200 border-t-indigo-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-5 mb-6 border-b border-gray-200">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Events</h1>
|
||||
<p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for each order event.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddForm(v => !v)}
|
||||
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"
|
||||
>
|
||||
{showAddForm ? 'Cancel' : '+ Add Event'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Generation readiness banner */}
|
||||
{!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">
|
||||
<span>⚠️</span>
|
||||
<span>Set up <strong>Provider</strong> and <strong>Global SMS cURL</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">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="text-red-500 hover:text-red-700 font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Event form */}
|
||||
{showAddForm && (
|
||||
<form onSubmit={handleAddEvent} className="mb-8 flex gap-3 p-5 rounded-xl bg-gray-50 border border-gray-200 shadow-sm">
|
||||
<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-indigo-600 text-sm shadow-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addingEvent || !newLabel.trim()}
|
||||
className="px-6 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium transition shadow-sm disabled:opacity-50"
|
||||
>
|
||||
{addingEvent ? 'Adding…' : 'Add'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Events list */}
|
||||
<div className="space-y-4">
|
||||
{events.map(event => {
|
||||
const state = genState[event.slug] || 'idle';
|
||||
const eventVariants = variants[event.slug] || [];
|
||||
|
||||
return (
|
||||
<div key={event.slug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* Event header */}
|
||||
<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 ? (
|
||||
<div className="mt-0.5 w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center border border-gray-200 shrink-0" title="Default event">
|
||||
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
|
||||
</div>
|
||||
) : (
|
||||
<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"
|
||||
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>
|
||||
<p className="text-xs text-gray-500 font-mono mt-0.5">{event.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{state === 'selected' && (
|
||||
<span className="text-xs font-semibold px-2.5 py-1 rounded-md bg-green-50 border border-green-200 text-green-700">
|
||||
✓ Template Selected
|
||||
</span>
|
||||
)}
|
||||
<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 ${
|
||||
state === 'done' || state === 'selected'
|
||||
? 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
: 'bg-indigo-50 border border-indigo-200 text-indigo-700 hover:bg-indigo-100'
|
||||
}`}
|
||||
>
|
||||
{state === 'loading' ? (
|
||||
<><span className="w-4 h-4 border-2 border-indigo-300 border-t-indigo-600 rounded-full animate-spin" /> Generating…</>
|
||||
) : state === 'done' || state === 'selected' ? (
|
||||
<>↺ Regenerate</>
|
||||
) : (
|
||||
<>⚡ Generate Template</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generated variants */}
|
||||
{eventVariants.length > 0 && (
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-6 py-5 space-y-4">
|
||||
<p className="text-xs text-gray-500 font-bold uppercase tracking-wider">Pick a Variant</p>
|
||||
<div className="grid gap-4">
|
||||
{eventVariants.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm hover:border-gray-300 transition"
|
||||
>
|
||||
<p className="text-sm text-gray-800 font-mono leading-relaxed">{v}</p>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-md ${v.length > 160 ? 'bg-red-50 text-red-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{v.length} / 160
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleSelect(event.slug, v)}
|
||||
disabled={selectingSlug === event.slug}
|
||||
className="text-xs px-4 py-2 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white font-bold transition disabled:opacity-50"
|
||||
>
|
||||
{selectingSlug === event.slug ? 'Selecting…' : 'Use this template'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import apiClient from '../api/client';
|
||||
import { useBusiness } from '../context/BusinessContext';
|
||||
|
||||
export default function GlobalSms() {
|
||||
const { businessId } = useParams();
|
||||
const { setHasGlobalSms } = useBusiness();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [activeProfileId, setActiveProfileId] = useState(null);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
// Form state for Create / Edit
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formCurl, setFormCurl] = useState('');
|
||||
const [formSetActive, setFormSetActive] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfiles();
|
||||
}, [businessId]);
|
||||
|
||||
async function loadProfiles() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
|
||||
setProfiles(res.data.profiles || []);
|
||||
setActiveProfileId(res.data.activeProfileId);
|
||||
if (res.data.activeProfileId) {
|
||||
setHasGlobalSms(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load cURL profiles');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddClick() {
|
||||
setEditingId(null);
|
||||
setFormName('');
|
||||
setFormCurl('');
|
||||
setFormSetActive(profiles.length === 0);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
}
|
||||
|
||||
function handleEditClick(profile) {
|
||||
setEditingId(profile.id);
|
||||
setFormName(profile.name);
|
||||
setFormCurl(profile.rawCurl);
|
||||
setFormSetActive(false); // only matters for create
|
||||
setError('');
|
||||
setSuccess('');
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!formName.trim() || !formCurl.trim()) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${editingId}`, {
|
||||
name: formName,
|
||||
rawCurl: formCurl,
|
||||
});
|
||||
setSuccess('Profile updated successfully.');
|
||||
} else {
|
||||
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles`, {
|
||||
name: formName,
|
||||
rawCurl: formCurl,
|
||||
setActive: formSetActive,
|
||||
});
|
||||
setSuccess('Profile created successfully.');
|
||||
}
|
||||
await loadProfiles();
|
||||
setFormName('');
|
||||
setFormCurl('');
|
||||
setEditingId(null);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to save cURL profile');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!window.confirm('Delete this cURL profile?')) return;
|
||||
try {
|
||||
await apiClient.delete(`/api/businesses/${businessId}/global-sms/profiles/${id}`);
|
||||
await loadProfiles();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to delete profile');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivate(id) {
|
||||
try {
|
||||
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${id}/activate`);
|
||||
await loadProfiles();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to activate profile');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<span className="w-8 h-8 border-4 border-spinner-track border-t-primary-blue rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8 pb-12">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">cURL Profiles</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
Manage the cURL commands used to generate and test SMS templates. The active profile will be used across the application.
|
||||
</p>
|
||||
</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">
|
||||
{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">
|
||||
{success}
|
||||
<button onClick={() => setSuccess('')} className="text-badge-text hover:opacity-75 font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profiles List */}
|
||||
<div className="space-y-4">
|
||||
{profiles.length > 0 ? (
|
||||
profiles.map(p => {
|
||||
const isActive = p.id === activeProfileId;
|
||||
return (
|
||||
<div key={p.id} className={`p-5 rounded-xl border ${isActive ? 'border-primary-blue bg-[#f0f2fb]' : '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 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">
|
||||
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">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted font-medium mb-2">Updated: {new Date(p.updatedAt).toLocaleString()}</p>
|
||||
<p className="text-sm font-mono text-text-muted bg-page-bg p-2 rounded border border-border-soft overflow-hidden whitespace-nowrap text-ellipsis max-w-xl">
|
||||
{p.rawCurl}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{!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"
|
||||
>
|
||||
Use this cURL
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleEditClick(p)}
|
||||
className="px-3 py-2 border border-border-main text-text-muted hover:text-primary-blue hover:border-primary-blue rounded-lg text-sm font-medium transition"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{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"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-12 bg-surface-white border border-border-dashed rounded-xl">
|
||||
<p className="text-sm font-medium text-text-muted mb-4">No cURL profiles configured yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline Form (Create / Edit) */}
|
||||
<div className="bg-surface-white border border-border-main rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-border-main bg-[#fafafa] flex items-center justify-between">
|
||||
<h3 className="font-bold text-text-primary text-md">
|
||||
{editingId ? 'Edit Profile' : 'Add New Profile'}
|
||||
</h3>
|
||||
{editingId && (
|
||||
<button onClick={handleAddClick} className="text-sm font-semibold text-primary-blue hover:underline">
|
||||
Switch to Add New
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Profile Name</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Raw cURL Command</label>
|
||||
<textarea
|
||||
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"
|
||||
required
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
{!editingId && profiles.length > 0 && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-primary-blue rounded border-border-main focus:ring-primary-blue"
|
||||
checked={formSetActive}
|
||||
onChange={e => setFormSetActive(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-text-primary">Set as active profile immediately</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<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"
|
||||
>
|
||||
{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>
|
||||
{editingId && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Cancel Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
export default function Providers() {
|
||||
const { businessId } = useParams();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
providerName: 'MSG91',
|
||||
senderId: '',
|
||||
dltEntityId: '',
|
||||
authKey: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await apiClient.get(`/api/businesses/${businessId}/providers`);
|
||||
if (res.data && res.data.providerName) {
|
||||
setForm({
|
||||
providerName: res.data.providerName || 'MSG91',
|
||||
senderId: res.data.senderId || '',
|
||||
dltEntityId: res.data.dltEntityId || '',
|
||||
authKey: res.data.authKey || '',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// no providers yet — keep defaults
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [businessId]);
|
||||
|
||||
function handleChange(field, value) {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
async function handleSave(e) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
if (form.senderId && !/^[A-Za-z]{6}$/.test(form.senderId)) {
|
||||
setError('DLT Sender ID must be exactly 6 alphabet characters');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiClient.post(`/api/businesses/${businessId}/providers`, form);
|
||||
setSuccess('Provider configuration saved successfully.');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-2 border-indigo-200 border-t-indigo-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<p className="text-sm text-gray-500 mt-1 font-medium">Save your DLT-approved sender details so the extension can dispatch SMS via your vendor.</p>
|
||||
</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">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="text-red-500 hover:text-red-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">
|
||||
{success}
|
||||
<button onClick={() => setSuccess('')} className="text-green-500 hover:text-green-700 font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSave} className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="p-6 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>}
|
||||
</label>
|
||||
<input
|
||||
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`}
|
||||
placeholder="e.g. MSG91, Gupshup"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<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>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
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`}
|
||||
placeholder="6 CHARS"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2 font-medium">Exactly 6 alphabetic characters (e.g. MOKOBA).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm font-semibold mb-1.5 tracking-wide ${!form.dltEntityId ? 'text-error-text' : 'text-text-primary'}`}>
|
||||
DLT Entity ID {(!form.dltEntityId) && <span className="text-error-text">*</span>}
|
||||
</label>
|
||||
<input
|
||||
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`}
|
||||
placeholder="19-digit DLT PE ID"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-1.5 tracking-wide">API Auth Key <span className="text-text-muted font-normal text-xs">(Optional)</span></label>
|
||||
<input
|
||||
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"
|
||||
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">
|
||||
<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"
|
||||
>
|
||||
{saving ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving…</> : 'Save Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import apiClient from '../api/client';
|
||||
import WhitelistModal from '../components/WhitelistModal';
|
||||
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' },
|
||||
};
|
||||
|
||||
export default function Templates() {
|
||||
const { businessId } = useParams();
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [whitelistTarget, setWhitelistTarget] = useState(null);
|
||||
const [testTarget, setTestTarget] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('published'); // 'published' | 'pending'
|
||||
|
||||
async function loadTemplates() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get(`/api/businesses/${businessId}/templates`);
|
||||
// Show all templates that have a selected template (status != generated or status exists)
|
||||
const all = (res.data.templates || []).filter(t => t.selectedTemplate);
|
||||
setTemplates(all);
|
||||
} catch {
|
||||
setError('Failed to load templates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadTemplates(); }, [businessId]);
|
||||
|
||||
function handleWhitelistSuccess(slug, templateId) {
|
||||
setTemplates(ts => ts.map(t =>
|
||||
t.eventSlug === slug ? { ...t, status: 'whitelisted', templateId } : t
|
||||
));
|
||||
setWhitelistTarget(null);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-2 border-indigo-200 border-t-indigo-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="pb-5 mb-6 border-b border-gray-200">
|
||||
<h1 className="text-2xl font-bold text-gray-900 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">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="text-error-text hover:text-red-900 font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex space-x-4 mb-6 border-b border-border-main">
|
||||
<button
|
||||
onClick={() => setActiveTab('published')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'published'
|
||||
? 'border-primary-blue text-primary-dark'
|
||||
: 'border-transparent text-text-muted hover:text-text-primary hover:border-border-main'
|
||||
}`}
|
||||
>
|
||||
Published
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('pending')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'pending'
|
||||
? 'border-primary-blue text-primary-dark'
|
||||
: 'border-transparent text-text-muted hover:text-text-primary hover:border-border-main'
|
||||
}`}
|
||||
>
|
||||
Pending Whitelisting
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<div className="text-center py-16 bg-surface-white border border-border-main rounded-xl shadow-sm">
|
||||
<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>
|
||||
<h3 className="text-lg font-bold text-text-primary">No Templates Yet</h3>
|
||||
<p className="text-text-muted text-sm mt-2 font-medium">Generate and select templates in the Events section first.</p>
|
||||
</div>
|
||||
) : (() => {
|
||||
const publishedTabs = templates.filter(t => t.status === 'whitelisted');
|
||||
const pendingTabs = templates.filter(t => t.status === 'pending_whitelisting');
|
||||
const visibleTemplates = activeTab === 'published' ? publishedTabs : pendingTabs;
|
||||
|
||||
if (visibleTemplates.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 bg-surface-white border border-border-dashed rounded-xl">
|
||||
<p className="text-text-muted text-sm font-medium">No templates in {activeTab === 'published' ? 'Published' : 'Pending'}.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{visibleTemplates.map(tmpl => {
|
||||
const statusCfg = STATUS_CONFIG[tmpl.status] || STATUS_CONFIG.generated;
|
||||
|
||||
return (
|
||||
<div key={tmpl.eventSlug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* Card header */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-gray-900 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>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-bold border ${statusCfg.bg} ${statusCfg.text} ${statusCfg.border}`}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Template text */}
|
||||
<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">
|
||||
{tmpl.selectedTemplate}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template ID (if whitelisted) */}
|
||||
{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-indigo-700 bg-indigo-50 border border-indigo-100 px-3 py-2 rounded-lg inline-block">
|
||||
{tmpl.templateId}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Variable map */}
|
||||
{tmpl.variableMap && Object.keys(tmpl.variableMap).length > 0 && (
|
||||
<div>
|
||||
<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">
|
||||
<span className="font-mono text-indigo-700 font-bold">{key}</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="font-medium text-gray-700">{val}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
{tmpl.status === 'pending_whitelisting' && (
|
||||
<button
|
||||
onClick={() => setWhitelistTarget(tmpl)}
|
||||
className="px-4 py-2 rounded-lg bg-orange-text hover:bg-[#c97b45] text-white text-sm font-semibold transition shadow-sm"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
{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"
|
||||
>
|
||||
<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
|
||||
</button>
|
||||
)}
|
||||
{tmpl.status === 'pending_whitelisting' && (
|
||||
<p className="text-xs text-text-muted font-medium">Submit to DLT portal, then enter your Template ID here.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{whitelistTarget && (
|
||||
<WhitelistModal
|
||||
businessId={businessId}
|
||||
template={whitelistTarget}
|
||||
onClose={() => setWhitelistTarget(null)}
|
||||
onSuccess={handleWhitelistSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{testTarget && (
|
||||
<TestSmsModal
|
||||
businessId={businessId}
|
||||
template={testTarget}
|
||||
onClose={() => setTestTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "SMS_Extension",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY index.js ./
|
||||
COPY config ./config
|
||||
COPY routes ./routes
|
||||
COPY services ./services
|
||||
EXPOSE 3001
|
||||
CMD ["node", "index.js"]
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
app: sms-extension-backend
|
||||
region: asia-south1
|
||||
entrypoint: "index.js"
|
||||
|
||||
build:
|
||||
builtin: dockerfile
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
const DEFAULT_EVENTS = [
|
||||
{ slug: 'placed', label: 'Placed', isDefault: true },
|
||||
{ slug: 'confirmed', label: 'Confirmed', isDefault: true },
|
||||
{ slug: 'dp_assigned', label: 'DP Assigned', isDefault: true },
|
||||
{ slug: 'pack', label: 'Pack', isDefault: true },
|
||||
{ slug: 'cancelled', label: 'Cancelled', isDefault: true },
|
||||
{ slug: 'delivery_done', label: 'Delivery Done', isDefault: true },
|
||||
];
|
||||
|
||||
module.exports = DEFAULT_EVENTS;
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
|
||||
const businessesRoutes = require('./routes/businesses');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => res.json({ ok: true, timestamp: new Date().toISOString() }));
|
||||
|
||||
// Routes
|
||||
app.use('/api/businesses', businessesRoutes);
|
||||
|
||||
// 404
|
||||
app.use('*', (req, res) => res.status(404).json({ error: 'Route not found' }));
|
||||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`SMS Extension server running on port ${PORT}`);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "sms-extension-server",
|
||||
"version": "1.0.0",
|
||||
"description": "SMS Template Extension Backend",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pixelbin/admin": "^2.0.0",
|
||||
"axios": "^1.6.8",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"openai": "^4.28.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { scrape } = require('../services/firecrawl');
|
||||
const { parseBrandContext } = require('../services/openai2');
|
||||
const { uploadImageFromUrl, uploadJSON, fetchJSON, deleteAllBrandFiles } = require('../services/pixelbin');
|
||||
const DEFAULT_EVENTS = require('../config/defaultEvents');
|
||||
|
||||
// POST /api/brand/register
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { websiteUrl } = req.body;
|
||||
if (!websiteUrl) return res.status(400).json({ error: 'websiteUrl is required' });
|
||||
|
||||
const merchantId = process.env.MERCHANT_ID;
|
||||
const merchantFolder = `brands/${merchantId}`;
|
||||
const imagesFolder = `${merchantFolder}/images`;
|
||||
|
||||
// 409 if brand already registered
|
||||
const existing = await fetchJSON(merchantFolder, 'context');
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Brand already registered. Delete the current brand first to re-register.' });
|
||||
}
|
||||
|
||||
// 1. Scrape
|
||||
const scrapedData = await scrape(websiteUrl);
|
||||
|
||||
// 2. Parse brand context
|
||||
const brandContext = await parseBrandContext(scrapedData);
|
||||
|
||||
// 3. Upload relevant images to Pixelbin
|
||||
const imagePaths = [];
|
||||
for (let i = 0; i < Math.min((brandContext.relevantImageUrls || []).length, 5); i++) {
|
||||
const url = await uploadImageFromUrl(brandContext.relevantImageUrls[i], imagesFolder, `image_${i + 1}`);
|
||||
if (url) imagePaths.push(url);
|
||||
}
|
||||
|
||||
// 4. Build and upload context.json
|
||||
let domain = '';
|
||||
try { domain = new URL(websiteUrl).hostname; } catch { }
|
||||
|
||||
const contextJson = {
|
||||
merchantId,
|
||||
domain,
|
||||
brandName: brandContext.brandName || 'Unknown Brand',
|
||||
tone: brandContext.tone || 'professional',
|
||||
taglines: brandContext.taglines || [],
|
||||
colors: brandContext.colors || [],
|
||||
relevantImagePaths: imagePaths,
|
||||
scrapedAt: new Date().toISOString(),
|
||||
};
|
||||
await uploadJSON(merchantFolder, 'context', contextJson);
|
||||
|
||||
// 5. Init events.json
|
||||
await uploadJSON(merchantFolder, 'events', { events: DEFAULT_EVENTS });
|
||||
|
||||
res.json(contextJson);
|
||||
} catch (err) {
|
||||
console.error('Brand register error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/brand
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const context = await fetchJSON(`brands/${process.env.MERCHANT_ID}`, 'context');
|
||||
if (!context) return res.status(404).json({ error: 'No brand registered.' });
|
||||
res.json(context);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/brand — wipe all brand files from Pixelbin
|
||||
router.delete('/', async (req, res) => {
|
||||
try {
|
||||
const merchantId = process.env.MERCHANT_ID;
|
||||
await deleteAllBrandFiles(merchantId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('Brand delete error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,803 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { scrape } = require('../services/firecrawl');
|
||||
const { parseBrandContext, generateTemplates, processCurl } = require('../services/openai2');
|
||||
const { sendViaWorkflow } = require('../services/workflowSender');
|
||||
const {
|
||||
uploadJSON,
|
||||
fetchJSON,
|
||||
uploadImageFromUrl,
|
||||
listImages,
|
||||
listTemplateFiles,
|
||||
deleteBusinessFiles,
|
||||
} = require('../services/pixelbin');
|
||||
const DEFAULT_EVENTS = require('../config/defaultEvents');
|
||||
const axios = require('axios');
|
||||
|
||||
const MERCHANT_ID = () => process.env.MERCHANT_ID;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function slugify(text) {
|
||||
return text.toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
function businessRoot(merchantId, businessId) {
|
||||
return `${merchantId}/${businessId}`;
|
||||
}
|
||||
|
||||
function indexPath(merchantId) {
|
||||
return merchantId; // index.json lives at {merchantId}/index.json
|
||||
}
|
||||
|
||||
async function getIndex(merchantId) {
|
||||
const data = await fetchJSON(indexPath(merchantId), 'index');
|
||||
return Array.isArray(data?.businesses) ? data.businesses : [];
|
||||
}
|
||||
|
||||
async function saveIndex(merchantId, businesses) {
|
||||
await uploadJSON(indexPath(merchantId), 'index', { businesses });
|
||||
}
|
||||
|
||||
// ─── Business CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/businesses
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const businesses = await getIndex(MERCHANT_ID());
|
||||
res.json({ businesses });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/businesses — create new business from websiteUrl
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { websiteUrl } = req.body;
|
||||
if (!websiteUrl) return res.status(400).json({ error: 'websiteUrl is required' });
|
||||
|
||||
const merchantId = MERCHANT_ID();
|
||||
const businessId = uuidv4();
|
||||
const bizRoot = businessRoot(merchantId, businessId);
|
||||
const imagesFolder = `${bizRoot}/images`;
|
||||
|
||||
// 1. Scrape
|
||||
const scrapedData = await scrape(websiteUrl);
|
||||
|
||||
// 2. Parse brand context
|
||||
const brandContext = await parseBrandContext(scrapedData);
|
||||
|
||||
// 3. Upload relevant images
|
||||
const imagePaths = [];
|
||||
for (let i = 0; i < Math.min((brandContext.relevantImageUrls || []).length, 5); i++) {
|
||||
const url = await uploadImageFromUrl(brandContext.relevantImageUrls[i], imagesFolder, `image_${i + 1}`);
|
||||
if (url) imagePaths.push(url);
|
||||
}
|
||||
|
||||
// 4. Build and upload context.json
|
||||
let domain = '';
|
||||
try { domain = new URL(websiteUrl).hostname; } catch { }
|
||||
|
||||
const contextJson = {
|
||||
businessId,
|
||||
merchantId,
|
||||
domain,
|
||||
brandName: brandContext.brandName || 'Unknown Brand',
|
||||
tone: brandContext.tone || 'professional',
|
||||
taglines: brandContext.taglines || [],
|
||||
colors: brandContext.colors || [],
|
||||
relevantImagePaths: imagePaths,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await uploadJSON(bizRoot, 'context', contextJson);
|
||||
|
||||
// 5. Init events.json
|
||||
await uploadJSON(bizRoot, 'events', { events: DEFAULT_EVENTS });
|
||||
|
||||
// 6. Update index.json
|
||||
const businesses = await getIndex(merchantId);
|
||||
businesses.push({
|
||||
businessId,
|
||||
brandName: contextJson.brandName,
|
||||
domain: contextJson.domain,
|
||||
createdAt: contextJson.createdAt,
|
||||
updatedAt: contextJson.updatedAt,
|
||||
});
|
||||
await saveIndex(merchantId, businesses);
|
||||
|
||||
res.json(contextJson);
|
||||
} catch (err) {
|
||||
console.error('Create business error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/businesses/:businessId
|
||||
router.get('/:businessId', async (req, res) => {
|
||||
try {
|
||||
const { businessId } = req.params;
|
||||
const context = await fetchJSON(businessRoot(MERCHANT_ID(), businessId), 'context');
|
||||
if (!context) return res.status(404).json({ error: 'Business not found' });
|
||||
res.json(context);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/businesses/:businessId
|
||||
router.delete('/:businessId', async (req, res) => {
|
||||
try {
|
||||
const merchantId = MERCHANT_ID();
|
||||
const { businessId } = req.params;
|
||||
|
||||
await deleteBusinessFiles(merchantId, businessId);
|
||||
|
||||
const businesses = await getIndex(merchantId);
|
||||
const updated = businesses.filter(b => b.businessId !== businessId);
|
||||
await saveIndex(merchantId, updated);
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('Delete business error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Providers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/businesses/:businessId/providers
|
||||
router.get('/:businessId/providers', async (req, res) => {
|
||||
try {
|
||||
const data = await fetchJSON(businessRoot(MERCHANT_ID(), req.params.businessId), 'providers');
|
||||
res.json(data || {});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/businesses/:businessId/providers
|
||||
// Mandatory fields for publish/send: providerName, senderId, dltEntityId.
|
||||
// Per plan: these are NOT required to save — user can save partial config and is only
|
||||
// blocked when switching a template to Published. senderId format is still validated
|
||||
// if provided, so the stored value is always valid.
|
||||
router.post('/:businessId/providers', async (req, res) => {
|
||||
try {
|
||||
const { providerName, senderId, dltEntityId, authKey } = req.body;
|
||||
|
||||
// If senderId is provided, it must still meet the format requirement
|
||||
if (senderId && (senderId.length !== 6 || !/^[A-Za-z]+$/.test(senderId))) {
|
||||
return res.status(400).json({ error: 'Sender ID must be exactly 6 alphabetic characters' });
|
||||
}
|
||||
|
||||
const config = {
|
||||
providerName: providerName || '',
|
||||
senderId: senderId ? senderId.toUpperCase() : '',
|
||||
dltEntityId: dltEntityId || '',
|
||||
authKey: authKey || '',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await uploadJSON(businessRoot(MERCHANT_ID(), req.params.businessId), 'providers', config);
|
||||
res.json(config);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Global SMS cURL (Compatibility layer — kept so existing sessions/frontend work) ────────────
|
||||
// The new multi-profile system is below. These two routes delegate to the active profile.
|
||||
|
||||
// GET /api/businesses/:businessId/global-sms
|
||||
// Returns the active cURL profile's rawCurl (or legacy global_sms.json as fallback).
|
||||
router.get('/:businessId/global-sms', async (req, res) => {
|
||||
try {
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||
const activeProfile = await getActiveProfile(bizRoot);
|
||||
if (activeProfile) {
|
||||
return res.json({ rawCurl: activeProfile.rawCurl, updatedAt: activeProfile.updatedAt });
|
||||
}
|
||||
// Fallback: legacy global_sms.json (present on businesses created before profile system)
|
||||
const data = await fetchJSON(bizRoot, 'global_sms');
|
||||
res.json(data || {});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/businesses/:businessId/global-sms
|
||||
// Compat: creates/updates a default profile and sets it active.
|
||||
router.post('/:businessId/global-sms', async (req, res) => {
|
||||
try {
|
||||
const { rawCurl } = req.body;
|
||||
if (!rawCurl || !rawCurl.trim()) {
|
||||
return res.status(400).json({ error: 'rawCurl is required' });
|
||||
}
|
||||
if (!rawCurl.trim().toLowerCase().startsWith('curl')) {
|
||||
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
|
||||
}
|
||||
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
|
||||
|
||||
const now = new Date().toISOString();
|
||||
// Find or create the default profile
|
||||
let defaultProfile = profileData.profiles.find(p => p.name === 'Default');
|
||||
if (defaultProfile) {
|
||||
defaultProfile.rawCurl = rawCurl.trim();
|
||||
defaultProfile.updatedAt = now;
|
||||
} else {
|
||||
defaultProfile = { id: uuidv4(), name: 'Default', rawCurl: rawCurl.trim(), isDefault: true, createdAt: now, updatedAt: now };
|
||||
profileData.profiles.push(defaultProfile);
|
||||
}
|
||||
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
||||
await uploadJSON(bizRoot, 'active_curl_profile', { profileId: defaultProfile.id, updatedAt: now });
|
||||
|
||||
res.json({ rawCurl: rawCurl.trim(), updatedAt: now });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── cURL Profile Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
async function getActiveProfile(bizRoot) {
|
||||
try {
|
||||
const [profileData, activeRec] = await Promise.all([
|
||||
fetchJSON(bizRoot, 'global_sms_profiles'),
|
||||
fetchJSON(bizRoot, 'active_curl_profile'),
|
||||
]);
|
||||
if (!profileData?.profiles?.length) return null;
|
||||
if (activeRec?.profileId) {
|
||||
const found = profileData.profiles.find(p => p.id === activeRec.profileId);
|
||||
if (found) return found;
|
||||
}
|
||||
// Fall back to first profile
|
||||
return profileData.profiles[0];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── cURL Profiles CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/businesses/:businessId/global-sms/profiles
|
||||
router.get('/:businessId/global-sms/profiles', async (req, res) => {
|
||||
try {
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||
const [profileData, activeRec] = await Promise.all([
|
||||
fetchJSON(bizRoot, 'global_sms_profiles'),
|
||||
fetchJSON(bizRoot, 'active_curl_profile'),
|
||||
]);
|
||||
const profiles = profileData?.profiles || [];
|
||||
const activeProfileId = activeRec?.profileId || (profiles[0]?.id ?? null);
|
||||
res.json({ profiles, activeProfileId });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/businesses/:businessId/global-sms/profiles
|
||||
router.post('/:businessId/global-sms/profiles', async (req, res) => {
|
||||
try {
|
||||
const { name, rawCurl, setActive } = req.body;
|
||||
if (!name || !String(name).trim()) {
|
||||
return res.status(400).json({ error: 'name is required' });
|
||||
}
|
||||
if (!rawCurl || !rawCurl.trim()) {
|
||||
return res.status(400).json({ error: 'rawCurl is required' });
|
||||
}
|
||||
if (!rawCurl.trim().toLowerCase().startsWith('curl')) {
|
||||
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
|
||||
}
|
||||
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const newProfile = { id: uuidv4(), name: String(name).trim(), rawCurl: rawCurl.trim(), isDefault: false, createdAt: now, updatedAt: now };
|
||||
profileData.profiles.push(newProfile);
|
||||
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
||||
|
||||
// Activate this profile if requested or if it is the first one
|
||||
if (setActive || profileData.profiles.length === 1) {
|
||||
await uploadJSON(bizRoot, 'active_curl_profile', { profileId: newProfile.id, updatedAt: now });
|
||||
}
|
||||
|
||||
res.json(newProfile);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/businesses/:businessId/global-sms/profiles/:profileId
|
||||
router.patch('/:businessId/global-sms/profiles/:profileId', async (req, res) => {
|
||||
try {
|
||||
const { businessId, profileId } = req.params;
|
||||
const { name, rawCurl } = req.body;
|
||||
|
||||
if (rawCurl !== undefined && !rawCurl.trim().toLowerCase().startsWith('curl')) {
|
||||
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
|
||||
}
|
||||
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
|
||||
const profile = profileData.profiles.find(p => p.id === profileId);
|
||||
if (!profile) return res.status(404).json({ error: 'Profile not found' });
|
||||
|
||||
if (name !== undefined) profile.name = String(name).trim();
|
||||
if (rawCurl !== undefined) profile.rawCurl = rawCurl.trim();
|
||||
profile.updatedAt = new Date().toISOString();
|
||||
|
||||
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
||||
res.json(profile);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/businesses/:businessId/global-sms/profiles/:profileId
|
||||
router.delete('/:businessId/global-sms/profiles/:profileId', async (req, res) => {
|
||||
try {
|
||||
const { businessId, profileId } = req.params;
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
|
||||
|
||||
const idx = profileData.profiles.findIndex(p => p.id === profileId);
|
||||
if (idx === -1) return res.status(404).json({ error: 'Profile not found' });
|
||||
if (profileData.profiles.length === 1) {
|
||||
return res.status(400).json({ error: 'Cannot delete the last cURL profile' });
|
||||
}
|
||||
|
||||
profileData.profiles.splice(idx, 1);
|
||||
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
||||
|
||||
// If deleted profile was active, switch to first remaining
|
||||
const activeRec = await fetchJSON(bizRoot, 'active_curl_profile');
|
||||
if (activeRec?.profileId === profileId) {
|
||||
await uploadJSON(bizRoot, 'active_curl_profile', { profileId: profileData.profiles[0].id, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/businesses/:businessId/global-sms/profiles/:profileId/activate
|
||||
router.post('/:businessId/global-sms/profiles/:profileId/activate', async (req, res) => {
|
||||
try {
|
||||
const { businessId, profileId } = req.params;
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
|
||||
const profile = profileData.profiles.find(p => p.id === profileId);
|
||||
if (!profile) return res.status(404).json({ error: 'Profile not found' });
|
||||
|
||||
await uploadJSON(bizRoot, 'active_curl_profile', { profileId, updatedAt: new Date().toISOString() });
|
||||
res.json({ activeProfileId: profileId });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/businesses/:businessId/global-sms/active
|
||||
router.get('/:businessId/global-sms/active', async (req, res) => {
|
||||
try {
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||
const [profileData, activeRec] = await Promise.all([
|
||||
fetchJSON(bizRoot, 'global_sms_profiles'),
|
||||
fetchJSON(bizRoot, 'active_curl_profile'),
|
||||
]);
|
||||
const profiles = profileData?.profiles || [];
|
||||
const activeProfileId = activeRec?.profileId || (profiles[0]?.id ?? null);
|
||||
const activeProfile = profiles.find(p => p.id === activeProfileId) || profiles[0] || null;
|
||||
res.json({ activeProfile, activeProfileId });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Events ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/businesses/:businessId/events
|
||||
router.get('/:businessId/events', async (req, res) => {
|
||||
try {
|
||||
const data = await fetchJSON(businessRoot(MERCHANT_ID(), req.params.businessId), 'events');
|
||||
res.json(data || { events: DEFAULT_EVENTS });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/businesses/:businessId/events — add custom event
|
||||
router.post('/:businessId/events', async (req, res) => {
|
||||
try {
|
||||
const { label } = req.body;
|
||||
if (!label) return res.status(400).json({ error: 'label is required' });
|
||||
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||
const data = await fetchJSON(bizRoot, 'events') || { events: [...DEFAULT_EVENTS] };
|
||||
const slug = slugify(label);
|
||||
|
||||
if (data.events.some(e => e.slug === slug)) {
|
||||
return res.status(409).json({ error: 'An event with this name already exists' });
|
||||
}
|
||||
|
||||
const newEvent = { slug, label, isDefault: false };
|
||||
data.events.push(newEvent);
|
||||
await uploadJSON(bizRoot, 'events', data);
|
||||
res.json(newEvent);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/businesses/:businessId/events/:slug
|
||||
router.delete('/:businessId/events/:slug', async (req, res) => {
|
||||
try {
|
||||
const { businessId, slug } = req.params;
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||
const data = await fetchJSON(bizRoot, 'events') || { events: [...DEFAULT_EVENTS] };
|
||||
const event = data.events.find(e => e.slug === slug);
|
||||
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
if (event.isDefault) return res.status(403).json({ error: 'Cannot delete a default event' });
|
||||
|
||||
data.events = data.events.filter(e => e.slug !== slug);
|
||||
await uploadJSON(bizRoot, 'events', data);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/businesses/:businessId/events/:slug/generate
|
||||
router.post('/:businessId/events/:slug/generate', async (req, res) => {
|
||||
try {
|
||||
const { businessId, slug } = req.params;
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||
|
||||
// Requirements check
|
||||
const [context, providers] = await Promise.all([
|
||||
fetchJSON(bizRoot, 'context'),
|
||||
fetchJSON(bizRoot, 'providers'),
|
||||
]);
|
||||
if (!context) return res.status(400).json({ error: 'Business context not found.' });
|
||||
if (!providers?.senderId) return res.status(400).json({ error: 'Provider details must be configured before generating templates.' });
|
||||
|
||||
// Require an active cURL profile (new system), falling back to legacy global_sms.json
|
||||
const activeProfile = await getActiveProfile(bizRoot);
|
||||
const legacyGlobalSms = activeProfile ? null : await fetchJSON(bizRoot, 'global_sms');
|
||||
const activeCurl = activeProfile?.rawCurl || legacyGlobalSms?.rawCurl || null;
|
||||
if (!activeCurl) {
|
||||
return res.status(400).json({ error: 'A cURL profile must be configured and active before generating templates.' });
|
||||
}
|
||||
|
||||
const eventsData = await fetchJSON(bizRoot, 'events') || { events: DEFAULT_EVENTS };
|
||||
const event = eventsData.events.find(e => e.slug === slug);
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
|
||||
const variants = await generateTemplates(context, slug, event.label);
|
||||
|
||||
const templateJson = {
|
||||
eventSlug: slug,
|
||||
eventLabel: event.label,
|
||||
generatedVariants: variants,
|
||||
selectedTemplate: null,
|
||||
status: 'generated',
|
||||
templateId: '',
|
||||
curlProfileId: activeProfile?.id || null,
|
||||
rawCurl: '',
|
||||
processedCurl: '',
|
||||
variableMap: {},
|
||||
selectedImagePath: '',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await uploadJSON(`${bizRoot}/templates`, slug, templateJson);
|
||||
res.json({ variants });
|
||||
} catch (err) {
|
||||
console.error('Generate error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Templates ────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/businesses/:businessId/templates/images (must be before /:slug)
|
||||
router.get('/:businessId/templates/images', async (req, res) => {
|
||||
try {
|
||||
const images = await listImages(`${businessRoot(MERCHANT_ID(), req.params.businessId)}/images`);
|
||||
res.json({ images });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/businesses/:businessId/templates
|
||||
router.get('/:businessId/templates', async (req, res) => {
|
||||
try {
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||
const folder = `${bizRoot}/templates`;
|
||||
const slugs = await listTemplateFiles(folder);
|
||||
const templates = [];
|
||||
for (const slug of slugs) {
|
||||
const tmpl = await fetchJSON(folder, slug);
|
||||
if (tmpl) templates.push(tmpl);
|
||||
}
|
||||
res.json({ templates });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/businesses/:businessId/templates/:slug
|
||||
router.get('/:businessId/templates/:slug', async (req, res) => {
|
||||
try {
|
||||
const { businessId, slug } = req.params;
|
||||
const tmpl = await fetchJSON(`${businessRoot(MERCHANT_ID(), businessId)}/templates`, slug);
|
||||
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
||||
res.json(tmpl);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/businesses/:businessId/templates/:slug/select
|
||||
router.post('/:businessId/templates/:slug/select', async (req, res) => {
|
||||
try {
|
||||
const { businessId, slug } = req.params;
|
||||
const { selectedVariant } = req.body;
|
||||
if (!selectedVariant) return res.status(400).json({ error: 'selectedVariant is required' });
|
||||
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||
const folder = `${bizRoot}/templates`;
|
||||
|
||||
const tmpl = await fetchJSON(folder, slug);
|
||||
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
||||
|
||||
// Resolve active cURL (new profile system first, legacy fallback)
|
||||
const activeProfile = await getActiveProfile(bizRoot);
|
||||
const legacyGlobalSms = activeProfile ? null : await fetchJSON(bizRoot, 'global_sms');
|
||||
const activeCurl = activeProfile?.rawCurl || legacyGlobalSms?.rawCurl || null;
|
||||
if (!activeCurl) {
|
||||
return res.status(400).json({ error: 'A cURL profile must be configured and active before selecting a template' });
|
||||
}
|
||||
|
||||
// Process the cURL against the selected template
|
||||
const { processedCurl, variableMap } = await processCurl(activeCurl, selectedVariant, slug);
|
||||
|
||||
tmpl.selectedTemplate = selectedVariant;
|
||||
tmpl.generatedVariants = []; // discard non-selected variants
|
||||
tmpl.status = 'pending_whitelisting';
|
||||
tmpl.curlProfileId = activeProfile?.id || null; // snapshot which profile was used
|
||||
tmpl.rawCurl = activeCurl;
|
||||
tmpl.processedCurl = processedCurl;
|
||||
tmpl.variableMap = variableMap;
|
||||
tmpl.updatedAt = new Date().toISOString();
|
||||
|
||||
await uploadJSON(folder, slug, tmpl);
|
||||
res.json(tmpl);
|
||||
} catch (err) {
|
||||
console.error('Select error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/businesses/:businessId/templates/:slug/whitelist
|
||||
router.post('/:businessId/templates/:slug/whitelist', async (req, res) => {
|
||||
try {
|
||||
const { businessId, slug } = req.params;
|
||||
const { templateId } = req.body;
|
||||
if (!templateId || !String(templateId).trim()) {
|
||||
return res.status(400).json({ error: 'templateId is required' });
|
||||
}
|
||||
|
||||
const folder = `${businessRoot(MERCHANT_ID(), businessId)}/templates`;
|
||||
const tmpl = await fetchJSON(folder, slug);
|
||||
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
||||
if (tmpl.status !== 'pending_whitelisting') {
|
||||
return res.status(400).json({ error: 'Template must be in pending_whitelisting status to whitelist' });
|
||||
}
|
||||
|
||||
tmpl.templateId = String(templateId).trim();
|
||||
tmpl.status = 'whitelisted';
|
||||
tmpl.updatedAt = new Date().toISOString();
|
||||
|
||||
await uploadJSON(folder, slug, tmpl);
|
||||
res.json(tmpl);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/businesses/:businessId/templates/:slug/publish
|
||||
// Handles transition from pending_whitelisting -> whitelisted (Published).
|
||||
// Validates mandatory provider fields, collects toNumber, then calls workflow sender.
|
||||
router.post('/:businessId/templates/:slug/publish', async (req, res) => {
|
||||
try {
|
||||
const { businessId, slug } = req.params;
|
||||
const { templateId, toNumber, providerName, senderId, dltEntityId, authKey } = req.body;
|
||||
|
||||
if (!templateId || !String(templateId).trim()) {
|
||||
return res.status(400).json({ error: 'templateId is required' });
|
||||
}
|
||||
if (!toNumber || !String(toNumber).trim()) {
|
||||
return res.status(400).json({ error: 'toNumber is required' });
|
||||
}
|
||||
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||
const folder = `${bizRoot}/templates`;
|
||||
|
||||
// Load template
|
||||
const tmpl = await fetchJSON(folder, slug);
|
||||
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
||||
if (tmpl.status !== 'pending_whitelisting') {
|
||||
return res.status(400).json({ error: 'Template must be in pending_whitelisting status to publish' });
|
||||
}
|
||||
|
||||
// Merge any submitted provider fields over stored values
|
||||
const storedProviders = await fetchJSON(bizRoot, 'providers') || {};
|
||||
const mergedProviders = {
|
||||
providerName: providerName || storedProviders.providerName || '',
|
||||
senderId: senderId ? senderId.toUpperCase() : (storedProviders.senderId || ''),
|
||||
dltEntityId: dltEntityId || storedProviders.dltEntityId || '',
|
||||
authKey: authKey || storedProviders.authKey || '',
|
||||
};
|
||||
|
||||
// Validate mandatory fields
|
||||
const missing = [];
|
||||
if (!mergedProviders.providerName) missing.push('providerName');
|
||||
if (!mergedProviders.senderId) missing.push('senderId');
|
||||
if (!mergedProviders.dltEntityId) missing.push('dltEntityId');
|
||||
if (missing.length > 0) {
|
||||
return res.status(422).json({
|
||||
error: 'Missing mandatory provider fields',
|
||||
missingFields: missing,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate senderId format
|
||||
if (mergedProviders.senderId.length !== 6 || !/^[A-Za-z]+$/.test(mergedProviders.senderId)) {
|
||||
return res.status(400).json({ error: 'Sender ID must be exactly 6 alphabetic characters' });
|
||||
}
|
||||
|
||||
// Persist any updated provider data
|
||||
const updatedProviders = { ...mergedProviders, updatedAt: new Date().toISOString() };
|
||||
await uploadJSON(bizRoot, 'providers', updatedProviders);
|
||||
|
||||
// Mark template as whitelisted
|
||||
tmpl.templateId = String(templateId).trim();
|
||||
tmpl.status = 'whitelisted';
|
||||
tmpl.updatedAt = new Date().toISOString();
|
||||
await uploadJSON(folder, slug, tmpl);
|
||||
|
||||
// Send via workflow (Published path)
|
||||
let sendResult;
|
||||
try {
|
||||
sendResult = await sendViaWorkflow({
|
||||
senderId: mergedProviders.senderId,
|
||||
toNumber: String(toNumber).trim(),
|
||||
content: tmpl.selectedTemplate || '',
|
||||
});
|
||||
} catch (sendErr) {
|
||||
// Template is already whitelisted at this point; report send error separately
|
||||
return res.status(502).json({
|
||||
error: 'Template published but send failed',
|
||||
details: sendErr.message,
|
||||
template: tmpl,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
template: tmpl,
|
||||
sendResult,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Publish error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/businesses/:businessId/templates/:slug/test
|
||||
// For Published (whitelisted) templates: routes to workflow sender (new path).
|
||||
// Legacy cURL execution code below (executeCurl) is kept intact and is NOT deleted.
|
||||
router.post('/:businessId/templates/:slug/test', async (req, res) => {
|
||||
try {
|
||||
const { businessId, slug } = req.params;
|
||||
const { toNumber } = req.body;
|
||||
if (!toNumber) return res.status(400).json({ error: 'toNumber is required' });
|
||||
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||
const folder = `${bizRoot}/templates`;
|
||||
const tmpl = await fetchJSON(folder, slug);
|
||||
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
||||
if (tmpl.status !== 'whitelisted') {
|
||||
return res.status(400).json({ error: 'Template must be whitelisted before testing' });
|
||||
}
|
||||
if (!tmpl.templateId) {
|
||||
return res.status(400).json({ error: 'templateId must be set before testing' });
|
||||
}
|
||||
|
||||
// ── Published send path: route to workflow sender ──────────────────────────
|
||||
// Per plan: Published (whitelisted) templates use the workflow sender module,
|
||||
// not the legacy cURL execution path. The cURL code below remains but is not
|
||||
// reached for whitelisted templates in this branch.
|
||||
const providers = await fetchJSON(bizRoot, 'providers') || {};
|
||||
if (!providers.senderId) {
|
||||
return res.status(422).json({ error: 'Provider senderId is required for sending' });
|
||||
}
|
||||
|
||||
let smsResult;
|
||||
try {
|
||||
smsResult = await sendViaWorkflow({
|
||||
senderId: providers.senderId,
|
||||
toNumber: String(toNumber).trim(),
|
||||
content: tmpl.selectedTemplate || '',
|
||||
});
|
||||
} catch (sendErr) {
|
||||
return res.status(502).json({ error: 'SMS send failed', details: sendErr.message });
|
||||
}
|
||||
|
||||
res.json({ success: true, statusCode: smsResult.statusCode, response: smsResult.response });
|
||||
|
||||
// ── Legacy cURL execution path (preserved, not reached for whitelisted) ────
|
||||
// The code below is kept for reference and future restoration.
|
||||
// It is NOT executed in the current Published send flow.
|
||||
/*
|
||||
let curlToExecute = String(tmpl.processedCurl || tmpl.rawCurl || '');
|
||||
curlToExecute = curlToExecute.replace(/\{#toNumber#\}/g, toNumber);
|
||||
curlToExecute = curlToExecute.replace(/\{#to#\}/g, toNumber);
|
||||
curlToExecute = curlToExecute.replace(/\{#mobile#\}/g, toNumber);
|
||||
curlToExecute = curlToExecute.replace(/\{#phone#\}/g, toNumber);
|
||||
|
||||
let legacyResult;
|
||||
try {
|
||||
legacyResult = await executeCurl(curlToExecute);
|
||||
} catch (curlErr) {
|
||||
return res.status(502).json({ error: 'SMS send failed', details: curlErr.message });
|
||||
}
|
||||
res.json({ success: true, statusCode: legacyResult.status, response: legacyResult.data });
|
||||
*/
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Minimal cURL parser: extracts method, URL, headers, and body from a cURL string,
|
||||
* then executes via axios.
|
||||
*/
|
||||
async function executeCurl(curlStr) {
|
||||
const method = /-X\s+([A-Z]+)/i.exec(curlStr)?.[1]?.toLowerCase() || 'post';
|
||||
const urlMatch = /curl\s+(?:-[^\s]+\s+)*['"]?(https?:\/\/[^\s'"]+)['"]?/i.exec(curlStr);
|
||||
if (!urlMatch) throw new Error('Could not parse URL from cURL command');
|
||||
const url = urlMatch[1];
|
||||
|
||||
// Extract headers (-H "Key: Value")
|
||||
const headers = {};
|
||||
const headerRegex = /-H\s+['"]([^'"]+)['"]/gi;
|
||||
let hMatch;
|
||||
while ((hMatch = headerRegex.exec(curlStr)) !== null) {
|
||||
const parts = hMatch[1].split(/:\s*(.+)/);
|
||||
if (parts.length >= 2) headers[parts[0].trim()] = parts[1].trim();
|
||||
}
|
||||
|
||||
// Extract body (-d or --data or --data-raw)
|
||||
const bodyMatch = /(?:--data-raw|--data|-d)\s+['"]([^'"]+)['"]/i.exec(curlStr)
|
||||
|| /(?:--data-raw|--data|-d)\s+(\S+)/i.exec(curlStr);
|
||||
const body = bodyMatch ? bodyMatch[1].replace(/\\n/g, '\n') : undefined;
|
||||
|
||||
return axios({
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
data: body,
|
||||
timeout: 15000,
|
||||
validateStatus: () => true, // don't throw on 4xx/5xx so we can relay the response
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { fetchJSON, uploadJSON } = require('../services/pixelbin');
|
||||
const { generateTemplates } = require('../services/openai2');
|
||||
const DEFAULT_EVENTS = require('../config/defaultEvents');
|
||||
|
||||
function slugify(text) {
|
||||
return text.toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
// GET /api/events
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const data = await fetchJSON(`brands/${process.env.MERCHANT_ID}`, 'events');
|
||||
res.json(data || { events: DEFAULT_EVENTS });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/events — add custom event
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { label } = req.body;
|
||||
if (!label) return res.status(400).json({ error: 'label is required' });
|
||||
|
||||
const folder = `brands/${process.env.MERCHANT_ID}`;
|
||||
const data = await fetchJSON(folder, 'events') || { events: [...DEFAULT_EVENTS] };
|
||||
const slug = slugify(label);
|
||||
|
||||
if (data.events.some(e => e.slug === slug)) {
|
||||
return res.status(409).json({ error: 'An event with this name already exists' });
|
||||
}
|
||||
|
||||
const newEvent = { slug, label, isDefault: false };
|
||||
data.events.push(newEvent);
|
||||
await uploadJSON(folder, 'events', data);
|
||||
res.json(newEvent);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/events/:slug
|
||||
router.delete('/:slug', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const folder = `brands/${process.env.MERCHANT_ID}`;
|
||||
const data = await fetchJSON(folder, 'events') || { events: [...DEFAULT_EVENTS] };
|
||||
const event = data.events.find(e => e.slug === slug);
|
||||
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
if (event.isDefault) return res.status(403).json({ error: 'Cannot delete a default event' });
|
||||
|
||||
data.events = data.events.filter(e => e.slug !== slug);
|
||||
await uploadJSON(folder, 'events', data);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/events/:slug/generate
|
||||
router.post('/:slug/generate', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const folder = `brands/${process.env.MERCHANT_ID}`;
|
||||
|
||||
const context = await fetchJSON(folder, 'context');
|
||||
if (!context) return res.status(400).json({ error: 'Register your business before generating templates.' });
|
||||
|
||||
const eventsData = await fetchJSON(folder, 'events') || { events: DEFAULT_EVENTS };
|
||||
const event = eventsData.events.find(e => e.slug === slug);
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
|
||||
const variants = await generateTemplates(context, slug, event.label);
|
||||
|
||||
const templateJson = {
|
||||
eventSlug: slug,
|
||||
eventLabel: event.label,
|
||||
generatedVariants: variants,
|
||||
approvedVariant: null,
|
||||
whitelistStatus: 'pending',
|
||||
rawCurl: '',
|
||||
processedCurl: '',
|
||||
variableMap: {},
|
||||
selectedImagePath: '',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await uploadJSON(`${folder}/templates`, slug, templateJson);
|
||||
res.json({ variants });
|
||||
} catch (err) {
|
||||
console.error('Generate error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { fetchJSON, uploadJSON } = require('../services/pixelbin');
|
||||
|
||||
// GET /api/providers
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const data = await fetchJSON(`brands/${process.env.MERCHANT_ID}`, 'providers');
|
||||
res.json(data || {});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/providers
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { providerName, senderId, dltEntityId } = req.body;
|
||||
if (!providerName || !senderId || !dltEntityId) {
|
||||
return res.status(400).json({ error: 'providerName, senderId, and dltEntityId are required' });
|
||||
}
|
||||
if (senderId.length !== 6 || !/^[A-Za-z]+$/.test(senderId)) {
|
||||
return res.status(400).json({ error: 'Sender ID must be exactly 6 alphabetic characters' });
|
||||
}
|
||||
|
||||
const config = {
|
||||
providerName,
|
||||
senderId: senderId.toUpperCase(),
|
||||
dltEntityId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await uploadJSON(`brands/${process.env.MERCHANT_ID}`, 'providers', config);
|
||||
res.json(config);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { fetchJSON, uploadJSON, listImages, listTemplateFiles } = require('../services/pixelbin');
|
||||
const { processCurl } = require('../services/openai2');
|
||||
|
||||
// GET /api/templates/images — must be BEFORE /:slug to avoid conflict
|
||||
router.get('/images', async (req, res) => {
|
||||
try {
|
||||
const images = await listImages(`brands/${process.env.MERCHANT_ID}/images`);
|
||||
res.json({ images });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/templates
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const merchantId = process.env.MERCHANT_ID;
|
||||
const folder = `brands/${merchantId}/templates`;
|
||||
console.log(`[Templates] GET /api/templates start | merchant=${merchantId} | pid=${process.pid}`);
|
||||
const slugs = await listTemplateFiles(folder);
|
||||
console.log(`[Templates] GET /api/templates files | merchant=${merchantId} | slugs=[${slugs.join(', ')}]`);
|
||||
const templates = [];
|
||||
for (const slug of slugs) {
|
||||
const tmpl = await fetchJSON(folder, slug);
|
||||
if (tmpl) {
|
||||
console.log(
|
||||
`[Templates] GET /api/templates item | merchant=${merchantId} | slug=${slug} | eventSlug=${tmpl.eventSlug || 'n/a'} | approved=${!!tmpl.approvedVariant} | updatedAt=${tmpl.updatedAt || 'n/a'}`
|
||||
);
|
||||
templates.push(tmpl);
|
||||
} else {
|
||||
console.log(`[Templates] GET /api/templates item-miss | merchant=${merchantId} | slug=${slug}`);
|
||||
}
|
||||
}
|
||||
console.log(`[Templates] GET /api/templates done | merchant=${merchantId} | count=${templates.length}`);
|
||||
res.json({ templates });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/templates/:slug
|
||||
router.get('/:slug', async (req, res) => {
|
||||
try {
|
||||
const merchantId = process.env.MERCHANT_ID;
|
||||
const slug = req.params.slug;
|
||||
const folder = `brands/${merchantId}/templates`;
|
||||
console.log(`[Templates] GET /api/templates/:slug start | merchant=${merchantId} | slug=${slug} | pid=${process.pid}`);
|
||||
const tmpl = await fetchJSON(folder, slug);
|
||||
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
||||
console.log(
|
||||
`[Templates] GET /api/templates/:slug done | merchant=${merchantId} | slug=${slug} | approved=${!!tmpl.approvedVariant} | updatedAt=${tmpl.updatedAt || 'n/a'}`
|
||||
);
|
||||
res.json(tmpl);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/templates/:slug/approve
|
||||
router.post('/:slug/approve', async (req, res) => {
|
||||
try {
|
||||
const { approvedVariant } = req.body;
|
||||
if (!approvedVariant) return res.status(400).json({ error: 'approvedVariant is required' });
|
||||
|
||||
const merchantId = process.env.MERCHANT_ID;
|
||||
const slug = req.params.slug;
|
||||
const folder = `brands/${merchantId}/templates`;
|
||||
console.log(
|
||||
`[Templates] POST /api/templates/:slug/approve start | merchant=${merchantId} | slug=${slug} | incomingLen=${approvedVariant.length} | pid=${process.pid}`
|
||||
);
|
||||
const tmpl = await fetchJSON(folder, slug);
|
||||
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
||||
console.log(
|
||||
`[Templates] POST /api/templates/:slug/approve before | merchant=${merchantId} | slug=${slug} | approved=${!!tmpl.approvedVariant} | updatedAt=${tmpl.updatedAt || 'n/a'}`
|
||||
);
|
||||
|
||||
tmpl.approvedVariant = approvedVariant;
|
||||
tmpl.whitelistStatus = 'submitted';
|
||||
tmpl.updatedAt = new Date().toISOString();
|
||||
await uploadJSON(folder, slug, tmpl);
|
||||
console.log(
|
||||
`[Templates] POST /api/templates/:slug/approve after | merchant=${merchantId} | slug=${slug} | approved=${!!tmpl.approvedVariant} | updatedAt=${tmpl.updatedAt}`
|
||||
);
|
||||
res.json(tmpl);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/templates/:slug/curl
|
||||
router.post('/:slug/curl', async (req, res) => {
|
||||
try {
|
||||
const { rawCurl } = req.body;
|
||||
if (!rawCurl) return res.status(400).json({ error: 'rawCurl is required' });
|
||||
|
||||
const folder = `brands/${process.env.MERCHANT_ID}/templates`;
|
||||
const tmpl = await fetchJSON(folder, req.params.slug);
|
||||
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
||||
if (!tmpl.approvedVariant) return res.status(400).json({ error: 'Template must have an approved variant first' });
|
||||
|
||||
const { processedCurl, variableMap } = await processCurl(rawCurl, tmpl.approvedVariant, req.params.slug);
|
||||
tmpl.rawCurl = rawCurl;
|
||||
tmpl.processedCurl = processedCurl;
|
||||
tmpl.variableMap = variableMap;
|
||||
tmpl.updatedAt = new Date().toISOString();
|
||||
await uploadJSON(folder, req.params.slug, tmpl);
|
||||
res.json({ processedCurl, variableMap });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/templates/:slug/image
|
||||
router.post('/:slug/image', async (req, res) => {
|
||||
try {
|
||||
const { imagePath } = req.body;
|
||||
if (imagePath === undefined) return res.status(400).json({ error: 'imagePath is required' });
|
||||
|
||||
const folder = `brands/${process.env.MERCHANT_ID}/templates`;
|
||||
const tmpl = await fetchJSON(folder, req.params.slug);
|
||||
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
||||
|
||||
tmpl.selectedImagePath = imagePath;
|
||||
tmpl.updatedAt = new Date().toISOString();
|
||||
await uploadJSON(folder, req.params.slug, tmpl);
|
||||
res.json(tmpl);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
const axios = require('axios');
|
||||
|
||||
/**
|
||||
* Scrape a website using Firecrawl.
|
||||
* Returns { markdown, links } — raw output passed to OpenAI.
|
||||
*/
|
||||
async function scrape(url) {
|
||||
const apiKey = process.env.FIRECRAWL_API_KEY;
|
||||
if (!apiKey) throw new Error('FIRECRAWL_API_KEY not set');
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'https://api.firecrawl.dev/v1/scrape',
|
||||
{ url, formats: ['markdown', 'links'] },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
}
|
||||
);
|
||||
|
||||
const data = response.data?.data || response.data;
|
||||
const markdownLen = typeof data?.markdown === 'string' ? data.markdown.length : 0;
|
||||
const linksCount = Array.isArray(data?.links) ? data.links.length : 0;
|
||||
|
||||
console.log(
|
||||
`[Firecrawl] scrape success | status=${response.status} | markdownLen=${markdownLen} | links=${linksCount}`
|
||||
);
|
||||
|
||||
return {
|
||||
markdown: data.markdown || '',
|
||||
links: data.links || [],
|
||||
};
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { scrape };
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
const OpenAI = require('openai');
|
||||
|
||||
const WORKFLOW = process.env.WORKFLOW_URL;
|
||||
|
||||
function getClient() {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) throw new Error('OPENAI_API_KEY not set');
|
||||
return new OpenAI({ apiKey });
|
||||
}
|
||||
|
||||
const TRAI_RULES = `TRAI SMS Template Rules:
|
||||
1. Maximum 160 characters for a single SMS (or 153 per part for multi-part)
|
||||
2. Dynamic variables must use the format {#var#}
|
||||
3. Transactional templates must not contain promotional content or URLs
|
||||
4. The sender ID (header) must be a 6-character alphabetic string registered with DLT
|
||||
5. No special characters except: . , - _ / @ and standard punctuation
|
||||
6. Template must clearly relate to the registered event type
|
||||
7. Do not include any URLs unless explicitly required by the event
|
||||
8. Template must start with context (e.g. "Your order..." not "Hi! Your order...")`;
|
||||
|
||||
const EVENT_DESCRIPTIONS = {
|
||||
placed: 'The customer has successfully placed an order',
|
||||
confirmed: 'The order has been confirmed by the seller/warehouse',
|
||||
dp_assigned: 'A delivery partner has been assigned to deliver the order',
|
||||
pack: 'The order has been packed and is ready for dispatch',
|
||||
cancelled: 'The order has been cancelled',
|
||||
delivery_done: 'The order has been successfully delivered to the customer',
|
||||
};
|
||||
|
||||
/** Parse scraped website data into structured brand context. */
|
||||
async function parseBrandContext(scrapedData) {
|
||||
const openai = getClient();
|
||||
const { markdown, links } = scrapedData;
|
||||
|
||||
const prompt = `You are a brand analyst. Analyze the website content below and extract structured brand information.
|
||||
|
||||
Website content (markdown):
|
||||
${markdown.slice(0, 8000)}
|
||||
|
||||
All links found on the page:
|
||||
${JSON.stringify(links.slice(0, 200))}
|
||||
|
||||
Return ONLY a valid JSON object:
|
||||
{
|
||||
"brandName": "string — the business/brand name",
|
||||
"tone": "one of: friendly, professional, formal, casual, energetic",
|
||||
"taglines": ["max 3 key brand taglines or phrases found on the page"],
|
||||
"colors": ["brand color hex codes if found, else empty array"],
|
||||
"relevantImageUrls": ["3-5 relevant brand image URLs — logos, hero images, product shots only. Exclude: social icons, nav icons, tracking pixels, data URIs, generic stock images. Only absolute URLs."]
|
||||
}`;
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3,
|
||||
});
|
||||
return JSON.parse(completion.choices[0].message.content);
|
||||
}
|
||||
|
||||
/** Generate 3 TRAI-compliant SMS template variants for a given event. */
|
||||
async function generateTemplates(brandContext, eventSlug, eventLabel) {
|
||||
const openai = getClient();
|
||||
const eventDesc = EVENT_DESCRIPTIONS[eventSlug] || `A "${eventLabel}" event in the order lifecycle`;
|
||||
|
||||
const prompt = `You are an SMS template expert for e-commerce in India.
|
||||
|
||||
Brand: ${brandContext.brandName}
|
||||
Tone: ${brandContext.tone}
|
||||
Taglines: ${(brandContext.taglines || []).join(', ') || 'none'}
|
||||
Event: "${eventLabel}" — ${eventDesc}
|
||||
|
||||
${TRAI_RULES}
|
||||
|
||||
Generate exactly 3 distinct SMS templates. Each must:
|
||||
- Strictly follow all TRAI rules
|
||||
- Use {#var#} for every dynamic variable (e.g. customer name, order ID, product name, tracking URL, delivery date)
|
||||
- Be under 160 characters
|
||||
- Reflect the brand tone
|
||||
- Start with order/event context
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{ "templates": ["template 1", "template 2", "template 3"] }`;
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.7,
|
||||
});
|
||||
const result = JSON.parse(completion.choices[0].message.content);
|
||||
return result.templates || [];
|
||||
}
|
||||
|
||||
/** Parse a raw provider cURL and map its placeholders to the approved template's {#var#} positions. */
|
||||
async function processCurl(rawCurl, approvedTemplate, eventSlug) {
|
||||
const openai = getClient();
|
||||
|
||||
const prompt = `You are an SMS provider integration expert.
|
||||
|
||||
Approved SMS template:
|
||||
${approvedTemplate}
|
||||
|
||||
Event: ${eventSlug}
|
||||
|
||||
Raw cURL from SMS provider:
|
||||
${rawCurl}
|
||||
|
||||
Analyze the cURL:
|
||||
1. Identify all placeholder formats (e.g. {{variable}}, %VAR%, <PLACEHOLDER>)
|
||||
2. Map each to a semantic field name (e.g. customerName, orderId, productName, trackingUrl, deliveryDate)
|
||||
3. Normalize to camelCase in processedCurl
|
||||
4. Build variableMap matching positional {#var#} slots in the approved template
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{
|
||||
"processedCurl": "cURL with normalized placeholder names",
|
||||
"variableMap": {
|
||||
"{#var#}[0]": "fieldName for first {#var#}",
|
||||
"{#var#}[1]": "fieldName for second {#var#}"
|
||||
}
|
||||
}`;
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.2,
|
||||
});
|
||||
return JSON.parse(completion.choices[0].message.content);
|
||||
}
|
||||
|
||||
module.exports = { parseBrandContext, generateTemplates, processCurl };
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
|
||||
const axios = require('axios');
|
||||
|
||||
const WORKFLOW_URL_SCRAPE = process.env.WORKFLOW_URL_SCRAPE;
|
||||
const WORKFLOW_URL_TEMPLATE = process.env.WORKFLOW_URL_TEMPLATE;
|
||||
const WORKFLOW_URL_CHECK_CURL = process.env.WORKFLOW_URL_CHECK_CURL;
|
||||
|
||||
if (!WORKFLOW_URL_SCRAPE) throw new Error('Missing WORKFLOW_URL_SCRAPE environment variable');
|
||||
if (!WORKFLOW_URL_TEMPLATE) throw new Error('Missing WORKFLOW_URL_TEMPLATE environment variable');
|
||||
if (!WORKFLOW_URL_CHECK_CURL) throw new Error('Missing WORKFLOW_URL_CHECK_CURL environment variable');
|
||||
|
||||
const TRAI_RULES_TEXT = '1) Max 160 chars. 2) Dynamic vars use {#var#}. 3) Transactional: no promo/URLs unless required. 4) Sender ID DLT-compliant. 5) Allowed punctuation only. 6) Must match event type. 7) Avoid URLs unless explicitly needed. 8) Start with event/order context.';
|
||||
|
||||
const EVENT_DESCRIPTIONS = {
|
||||
placed: 'The customer has successfully placed an order',
|
||||
confirmed: 'The order has been confirmed by the seller/warehouse',
|
||||
dp_assigned: 'A delivery partner has been assigned to deliver the order',
|
||||
pack: 'The order has been packed and is ready for dispatch',
|
||||
cancelled: 'The order has been cancelled',
|
||||
delivery_done: 'The order has been successfully delivered to the customer',
|
||||
};
|
||||
|
||||
function requestId(prefix) {
|
||||
return `${prefix}_${Date.now()}`;
|
||||
}
|
||||
|
||||
function parseJsonField(value, fallback) {
|
||||
if (typeof value !== 'string') return value ?? fallback;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function postWorkflow(url, payload) {
|
||||
try {
|
||||
const response = await axios.post(url, payload, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
maxBodyLength: Infinity,
|
||||
timeout: 60000,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const details = error.response?.data ? ` | response: ${JSON.stringify(error.response.data)}` : '';
|
||||
throw new Error(`Workflow API error (${url}): ${error.message}${details}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseBrandContext(scrapedData = {}) {
|
||||
const markdown = String(scrapedData.markdown || '').slice(0, 8000);
|
||||
const links = Array.isArray(scrapedData.links) ? scrapedData.links.slice(0, 200) : [];
|
||||
|
||||
const payload = {
|
||||
task: 'parse_brand_context',
|
||||
request_id: requestId('parse_brand_context'),
|
||||
markdown,
|
||||
links_json: JSON.stringify(links),
|
||||
metadata_json: JSON.stringify(scrapedData.metadata || {}),
|
||||
images_json: JSON.stringify(scrapedData.images || []),
|
||||
raw_json_blob: JSON.stringify(scrapedData.json || {}),
|
||||
output_schema_text: 'Return ONLY valid JSON object with exactly these keys: brandName (string), tone (one of: friendly, professional, formal, casual, energetic), taglines (array of strings, max 3), colors (array of hex color strings, or empty array), relevantImageUrls (array of 3-5 absolute image URLs for logo/hero/product images only; no icons/tracking/data URLs). No markdown, no prose, no extra keys.',
|
||||
must_return_json_only: 'true',
|
||||
};
|
||||
|
||||
const data = await postWorkflow(WORKFLOW_URL_SCRAPE, payload);
|
||||
const output = typeof data === 'string' ? parseJsonField(data, {}) : (data || {});
|
||||
|
||||
return {
|
||||
brandName: String(output.brandName || '').trim() || 'Unknown Brand',
|
||||
tone: ['friendly', 'professional', 'formal', 'casual', 'energetic'].includes(String(output.tone || '').toLowerCase())
|
||||
? String(output.tone).toLowerCase()
|
||||
: 'professional',
|
||||
taglines: Array.isArray(output.taglines) ? output.taglines.slice(0, 3).map(String) : [],
|
||||
colors: Array.isArray(output.colors) ? output.colors.map(String) : [],
|
||||
relevantImageUrls: Array.isArray(output.relevantImageUrls) ? output.relevantImageUrls.map(String) : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function generateTemplates(brandContext = {}, eventSlug, eventLabel) {
|
||||
const eventDesc = EVENT_DESCRIPTIONS[eventSlug] || `A "${eventLabel}" event in the order lifecycle`;
|
||||
|
||||
const payload = {
|
||||
task: 'generate_sms_templates',
|
||||
request_id: requestId('generate_sms_templates'),
|
||||
brand_name: String(brandContext.brandName || ''),
|
||||
tone: String(brandContext.tone || ''),
|
||||
taglines_json: JSON.stringify(Array.isArray(brandContext.taglines) ? brandContext.taglines : []),
|
||||
event_slug: String(eventSlug || ''),
|
||||
event_label: String(eventLabel || ''),
|
||||
event_description: eventDesc,
|
||||
trai_rules_text: TRAI_RULES_TEXT,
|
||||
templates_count: '3',
|
||||
max_chars: '160',
|
||||
variable_format: '{#var#}',
|
||||
output_schema_text: 'Return ONLY valid JSON object with exactly one key: templates (array of exactly 3 strings). No extra keys.',
|
||||
must_return_json_only: 'true',
|
||||
};
|
||||
|
||||
const data = await postWorkflow(WORKFLOW_URL_TEMPLATE, payload);
|
||||
const output = typeof data === 'string' ? parseJsonField(data, {}) : (data || {});
|
||||
const templates = Array.isArray(output.templates)
|
||||
? output.templates
|
||||
: parseJsonField(output.templates_json, []);
|
||||
|
||||
return Array.isArray(templates) ? templates.map(String).slice(0, 3) : [];
|
||||
}
|
||||
|
||||
async function processCurl(rawCurl, approvedTemplate, eventSlug) {
|
||||
const payload = {
|
||||
task: 'process_provider_curl',
|
||||
request_id: requestId('process_provider_curl'),
|
||||
raw_curl: String(rawCurl || ''),
|
||||
approved_template: String(approvedTemplate || ''),
|
||||
event_slug: String(eventSlug || ''),
|
||||
instructions_text: 'Identify placeholders, map to semantic field names, normalize placeholders in curl to camelCase, and build positional mapping for {#var#} tokens in approved_template.',
|
||||
output_schema_text: 'Return ONLY valid JSON object with exactly these keys: processedCurl (string), variableMap (object where keys are {#var#}[index] and values are field names in camelCase). No extra keys.',
|
||||
must_return_json_only: 'true',
|
||||
};
|
||||
|
||||
const data = await postWorkflow(WORKFLOW_URL_CHECK_CURL, payload);
|
||||
const output = typeof data === 'string' ? parseJsonField(data, {}) : (data || {});
|
||||
const variableMap = typeof output.variableMap === 'object' && output.variableMap !== null
|
||||
? output.variableMap
|
||||
: parseJsonField(output.variable_map_json, {});
|
||||
|
||||
return {
|
||||
processedCurl: String(output.processedCurl || ''),
|
||||
variableMap: variableMap && typeof variableMap === 'object' ? variableMap : {},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { parseBrandContext, generateTemplates, processCurl };
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
const { PixelbinConfig, PixelbinClient } = require('@pixelbin/admin');
|
||||
const { Readable } = require('stream');
|
||||
const axios = require('axios');
|
||||
|
||||
function getPixelbinClient() {
|
||||
const apiToken = process.env.PIXELBIN_API_TOKEN;
|
||||
if (!apiToken) throw new Error('PIXELBIN_API_TOKEN not set');
|
||||
const config = new PixelbinConfig({
|
||||
domain: 'https://api.pixelbin.io',
|
||||
apiSecret: apiToken,
|
||||
});
|
||||
return new PixelbinClient(config);
|
||||
}
|
||||
|
||||
/** Upload a JSON object to Pixelbin. Overwrites if already exists. */
|
||||
async function uploadJSON(folderPath, filename, jsonObject) {
|
||||
const pixelbin = getPixelbinClient();
|
||||
const buffer = Buffer.from(JSON.stringify(jsonObject, null, 2), 'utf-8');
|
||||
const stream = Readable.from(buffer);
|
||||
stream.path = `${filename}.json`;
|
||||
|
||||
await pixelbin.assets.fileUpload({
|
||||
file: stream,
|
||||
path: folderPath,
|
||||
name: filename,
|
||||
access: 'public-read',
|
||||
overwrite: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a JSON file from Pixelbin. Returns null if not found. */
|
||||
async function fetchJSON(folderPath, filename) {
|
||||
const pixelbin = getPixelbinClient();
|
||||
try {
|
||||
const result = await pixelbin.assets.listFiles({ path: folderPath });
|
||||
const items = result.items || [];
|
||||
const file = items.find(
|
||||
f => f.type === 'file' && (f.name === filename || f.name === `${filename}.json`)
|
||||
);
|
||||
if (!file?.url) return null;
|
||||
const bustUrl = `${file.url}?t=${Date.now()}`;
|
||||
const res = await axios.get(bustUrl, { timeout: 10000 });
|
||||
return res.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Download an image URL and upload it to Pixelbin. Returns CDN URL or null. */
|
||||
async function uploadImageFromUrl(imageUrl, folderPath, filename) {
|
||||
try {
|
||||
const pixelbin = getPixelbinClient();
|
||||
const res = await axios.get(imageUrl, { responseType: 'arraybuffer', timeout: 15000 });
|
||||
const buffer = Buffer.from(res.data);
|
||||
const rawExt = (imageUrl.split('?')[0].split('.').pop() || 'jpg').toLowerCase();
|
||||
const ext = ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(rawExt) ? rawExt : 'jpg';
|
||||
const stream = Readable.from(buffer);
|
||||
stream.path = `${filename}.${ext}`;
|
||||
const uploaded = await pixelbin.assets.fileUpload({
|
||||
file: stream,
|
||||
path: folderPath,
|
||||
name: filename,
|
||||
access: 'public-read',
|
||||
overwrite: true,
|
||||
});
|
||||
return uploaded?.url || null;
|
||||
} catch (err) {
|
||||
console.error(`Image upload failed (${imageUrl}):`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** List all image files in a Pixelbin folder. Returns [{ name, url }] */
|
||||
async function listImages(folderPath) {
|
||||
const pixelbin = getPixelbinClient();
|
||||
try {
|
||||
const result = await pixelbin.assets.listFiles({ path: folderPath });
|
||||
return (result.items || [])
|
||||
.filter(f => f.type === 'file')
|
||||
.map(f => ({ name: f.name, url: f.url }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** List template file names (slugs) in a Pixelbin folder. */
|
||||
async function listTemplateFiles(folderPath) {
|
||||
const pixelbin = getPixelbinClient();
|
||||
try {
|
||||
const result = await pixelbin.assets.listFiles({ path: folderPath });
|
||||
return (result.items || [])
|
||||
.filter(f => f.type === 'file')
|
||||
.map(f => f.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a single file from Pixelbin by its fileId. */
|
||||
async function deleteFile(fileId) {
|
||||
const pixelbin = getPixelbinClient();
|
||||
try {
|
||||
await pixelbin.assets.deleteFile({ fileId });
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete ${fileId}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** List all file items (with fileId) from a folder. */
|
||||
async function listFilesWithId(folderPath) {
|
||||
const pixelbin = getPixelbinClient();
|
||||
try {
|
||||
const result = await pixelbin.assets.listFiles({ path: folderPath });
|
||||
return (result.items || []).filter(f => f.type === 'file');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all files belonging to a specific business.
|
||||
* Business root: {merchantId}/{businessId}/
|
||||
*/
|
||||
async function deleteBusinessFiles(merchantId, businessId) {
|
||||
const root = `${merchantId}/${businessId}`;
|
||||
const [rootFiles, templateFiles, imageFiles] = await Promise.all([
|
||||
listFilesWithId(root),
|
||||
listFilesWithId(`${root}/templates`),
|
||||
listFilesWithId(`${root}/images`),
|
||||
]);
|
||||
|
||||
const all = [...rootFiles, ...templateFiles, ...imageFiles];
|
||||
await Promise.all(all.map(f => deleteFile(f.fileId)));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadJSON,
|
||||
fetchJSON,
|
||||
uploadImageFromUrl,
|
||||
listImages,
|
||||
listTemplateFiles,
|
||||
deleteFile,
|
||||
listFilesWithId,
|
||||
deleteBusinessFiles,
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* workflowSender.js
|
||||
*
|
||||
* Temporary workflow-based send module for Published (whitelisted) templates.
|
||||
* This is a separate code path from the legacy cURL execution route.
|
||||
* The legacy cURL code in businesses.js is preserved and NOT removed.
|
||||
*
|
||||
* Uses env var: WORKFLOW_URL_TEST_SMS
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
/**
|
||||
* Send an SMS via the temporary workflow endpoint.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} params.senderId - Provider sender ID
|
||||
* @param {string} params.toNumber - Destination phone number
|
||||
* @param {string} params.content - Resolved template content
|
||||
* @returns {Promise<{ success: boolean, statusCode: number, response: any }>}
|
||||
*/
|
||||
async function sendViaWorkflow({ senderId, toNumber, content }) {
|
||||
const workflowUrl = process.env.WORKFLOW_URL_TEST_SMS;
|
||||
|
||||
if (!workflowUrl || !workflowUrl.trim()) {
|
||||
throw new Error('WORKFLOW_URL_TEST_SMS is not configured');
|
||||
}
|
||||
|
||||
const payload = { senderId, toNumber, content };
|
||||
|
||||
const response = await axios.post(workflowUrl.trim(), payload, {
|
||||
timeout: 20000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
validateStatus: () => true, // relay all status codes; don't throw on 4xx/5xx
|
||||
});
|
||||
|
||||
return {
|
||||
success: response.status >= 200 && response.status < 300,
|
||||
statusCode: response.status,
|
||||
response: response.data,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { sendViaWorkflow };
|
||||
Loading…
Reference in New Issue
Block a user