First commit

This commit is contained in:
Ritul-Work 2026-03-26 14:19:26 +05:30
commit 25ba4c1937
47 changed files with 9395 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@ -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

24
client/.gitignore vendored Normal file
View File

@ -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?

29
client/eslint.config.js Normal file
View File

@ -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_]' }],
},
},
])

13
client/index.html Normal file
View File

@ -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>

3283
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
client/package.json Normal file
View File

@ -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

24
client/public/icons.svg Normal file
View File

@ -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

184
client/src/App.css Normal file
View File

@ -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);
}
}

85
client/src/App.jsx Normal file
View File

@ -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>
);
}

8
client/src/api/client.js Normal file
View File

@ -0,0 +1,8 @@
import axios from 'axios';
const apiClient = axios.create({
baseURL: '/',
headers: { 'Content-Type': 'application/json' },
});
export default apiClient;

BIN
client/src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -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

View File

@ -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>
);
}

View File

@ -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 2030 seconds.
</p>
)}
</form>
</>
)}
</div>
</div>
);
}

View File

@ -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">&larr;</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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -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);
}

65
client/src/index.css Normal file
View File

@ -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;
}

10
client/src/main.jsx Normal file
View File

@ -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>,
)

210
client/src/pages/Brand.jsx Normal file
View File

@ -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>
);
}

View File

@ -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">&times;</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>
);
}

246
client/src/pages/Events.jsx Normal file
View File

@ -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">&times;</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>
);
}

View File

@ -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">&times;</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">&times;</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>
);
}

View File

@ -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">&times;</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">&times;</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>
);
}

View File

@ -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">&times;</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>
);
}

18
client/vite.config.js Normal file
View File

@ -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,
},
},
},
})

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "SMS_Extension",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

11
server/Dockerfile Normal file
View File

@ -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"]

6
server/boltic.yaml Normal file
View File

@ -0,0 +1,6 @@
app: sms-extension-backend
region: asia-south1
entrypoint: "index.js"
build:
builtin: dockerfile

View File

@ -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;

30
server/index.js Normal file
View File

@ -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}`);
});

1894
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
server/package.json Normal file
View File

@ -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"
}
}

86
server/routes/brand.js Normal file
View File

@ -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;

803
server/routes/businesses.js Normal file
View File

@ -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;

99
server/routes/events.js Normal file
View File

@ -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;

View File

@ -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;

134
server/routes/templates.js Normal file
View File

@ -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;

View File

@ -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 };

133
server/services/openai.js Normal file
View File

@ -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 };

133
server/services/openai2.js Normal file
View File

@ -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 };

145
server/services/pixelbin.js Normal file
View File

@ -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,
};

View File

@ -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 };