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