Tonne of udpatess

This commit is contained in:
bunker-admin 2026-02-18 10:01:54 -07:00
parent 99a6abab06
commit 56e262ad8b
197 changed files with 42200 additions and 968 deletions

View File

@ -46,7 +46,8 @@ INITIAL_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
# --- API ---
API_PORT=4000
API_URL=http://localhost:4000
CORS_ORIGINS=http://localhost:3000,http://localhost
# Include docs/root domain for inline payment widgets on MkDocs pages
CORS_ORIGINS=http://localhost:3000,http://localhost,http://localhost:4003
# --- Admin GUI ---
ADMIN_PORT=3000
@ -114,6 +115,11 @@ NC_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
REDIS_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
REDIS_URL=redis://:${REDIS_PASSWORD}@redis-changemaker:6379
# --- Payments (Stripe) ---
# Enable payments feature (memberships, products, donations)
# Stripe API keys are stored encrypted in DB via admin settings page
ENABLE_PAYMENTS=false
# --- Media Management ---
ENABLE_MEDIA_FEATURES=false
MEDIA_API_PORT=4100

161
README.md
View File

@ -1,140 +1,85 @@
# Changemaker Lite
Changemaker Lite is a streamlined documentation and development platform featuring essential self-hosted services for creating, managing, and automating political campaign workflows.
A self-hosted political campaign platform that consolidates advocacy email campaigns, geographic mapping, volunteer canvassing, media management, and administration into a single TypeScript stack. Built for organizers who want to own their data.
## Features
## What Is This?
- **Homepage**: Modern dashboard for accessing all services
- **Code Server**: VS Code in your browser for remote development
- **MkDocs Material**: Beautiful documentation with live preview
- **Static Site Server**: High-performance hosting for built sites
- **Listmonk**: Self-hosted newsletter and email campaign management
- **PostgreSQL**: Reliable database backend
- **n8n**: Workflow automation and service integration
- **NocoDB**: No-code database platform and smart spreadsheet interface
- **Map**: Interactive map visualization for geographic data with real-time geolocation, walk sheet generation, and QR code integration
- **Influence**: Campaign tool for connecting Alberta residents with elected representatives at all government levels
Changemaker Lite gives community organizers the tools they need to:
- **Run advocacy campaigns** — let supporters look up their elected representatives by postal code and send emails in a few clicks
- **Manage canvassing** — map locations, draw canvassing areas, schedule volunteer shifts, and track door-to-door visits with GPS
- **Host media** — upload campaign videos, share them publicly, and track engagement analytics
- **Build landing pages** — drag-and-drop page builder for campaign microsites
- **Send newsletters** — integrated with Listmonk for opt-in mailing lists
- **Monitor everything** — Prometheus + Grafana observability stack included
The entire platform runs on Docker Compose with a single `.env` file for configuration.
## Quick Start
The whole system can be set up in minutes using Docker Compose. It is recommended to run this on a server with at least 8GB of RAM and 4 CPU cores for optimal performance. Instructions to build to production are available in the mkdocs/docs/build directory, at cmlite.org, or in the site preview.
```bash
# Clone the repository
git clone https://gitea.bnkops.com/admin/changemaker.lite
# Clone and switch to the v2 branch
git clone <repo-url> changemaker.lite
cd changemaker.lite
git checkout v2
# Configure environment (creates .env file)
./config.sh
# Create your environment file
cp .env.example .env
# Edit .env — at minimum set:
# V2_POSTGRES_PASSWORD, REDIS_PASSWORD,
# JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, ENCRYPTION_KEY
# INITIAL_ADMIN_EMAIL, INITIAL_ADMIN_PASSWORD
# Start all services
docker compose up -d
# Start core services
docker compose up -d v2-postgres redis api admin
# Run database migrations and seed
docker compose exec api npx prisma migrate deploy
docker compose exec api npx prisma db seed
```
## Map
Then open **http://localhost:3000** and log in with the admin credentials from your `.env`.
Instructions on how to build the map are available in the map directory.
## Architecture
Instructions on how to build for production are available in the mkdocs/docs/build directory or in the site preview.
| Component | Technology | Port |
|-----------|-----------|------|
| **API** | Express.js + Prisma + PostgreSQL | 4000 |
| **Media API** | Fastify + Prisma (shared DB) | 4100 |
| **Admin GUI** | React + Vite + Ant Design + Zustand | 3000 |
| **Reverse Proxy** | Nginx (subdomain routing) | 80 |
| **Database** | PostgreSQL 16 | 5433 |
| **Cache / Queue** | Redis + BullMQ | 6379 |
| **Newsletter** | Listmonk | 9001 |
| **Monitoring** | Prometheus + Grafana + Alertmanager | 9090, 3001 |
### Quick Start for Map
See `CLAUDE.md` for comprehensive architecture documentation, module reference, and troubleshooting.
Update the .env file in the map directory with your NocoDB URLs, and then run:
## Feature Flags
Enable optional modules in `.env`:
```bash
cd map
docker compose up -d
ENABLE_MEDIA_FEATURES=true # Video library + gallery
LISTMONK_SYNC_ENABLED=true # Newsletter subscriber sync
EMAIL_TEST_MODE=true # Route emails to MailHog (dev)
```
## Influence
The Influence Campaign Tool helps Alberta residents connect with elected representatives at federal, provincial, and municipal levels. Users can look up representatives by postal code and send advocacy emails through customizable campaigns.
Detailed setup and configuration instructions are available in the `influence/README.MD` file.
### Quick Start for Influence
Configure your environment and start the service:
```bash
cd influence
cp example.env .env
# Edit .env with your NocoDB and SMTP settings
./scripts/build-nocodb.sh # Set up database tables
docker compose up -d
```
## Service Access
After starting, access services at:
- **Homepage Dashboard**: http://localhost:3010
- **Documentation (Dev)**: http://localhost:4000
- **Documentation (Built)**: http://localhost:4001
- **Code Server**: http://localhost:8888
- **Listmonk**: http://localhost:9000
- **n8n**: http://localhost:5678
- **NocoDB**: http://localhost:8090
- **Map Viewer**: http://localhost:3000
- **Influence Campaign Tool**: http://localhost:3333
## Production Deployment
If you are deploying to production, using Cloudflare, you can use the included 'start-production.sh' script to set up a secure deployment with HTTPS. Ensure your domain and cloudflare settings are correctly configured in the root .env before running. More information on the required API tokens and settings can be found in the mkdocs/docs/build directory or at cmlite.org.
```bash
./start-production.sh
```
Changemaker Lite uses [Pangolin](https://github.com/fosrl/pangolin) tunnels for production access (Cloudflare alternative). See the Tunnel page in the admin panel (`/app/tunnel`) for setup instructions.
## Documentation
Complete documentation is available in the MkDocs site, including:
- Service configuration guides
- Integration examples
- Workflow automation tutorials
- Map application setup and usage
- Troubleshooting guides
Visit http://localhost:4000 after starting services to access the full documentation.
- **`CLAUDE.md`** — Full project reference (architecture, modules, ports, patterns)
- **`V2_PLAN.md`** — Development roadmap (Phases 1-14 complete)
- **`SECURITY_AUDIT_2025-02-11.md`** — Security audit findings and remediations
- **`.env.example`** — All 100+ environment variables with descriptions
## Licensing
This project is licensed under the Apache License 2.0 - https://opensource.org/license/apache-2-0
This project is licensed under the [Apache License 2.0](https://opensource.org/license/apache-2-0).
## AI Disclaimer
This project used AI tools to assist in its creation and large amounts of the boilerplate code was reviewed using AI. AI tools (although not activated or connected) are pre-installed in the Coder docker image. See `docker.code-server` for more details.
While these tools can help generate code and documentation, they may also introduce errors or inaccuracies. Users should review and test all content to ensure it meets their requirements and standards.
## Troubleshooting
### Permission Denied Errors (EACCES)
If you see errors like `EACCES: permission denied` when starting containers, run the included fix script:
```bash
./fix-permissions.sh
```
This fixes permissions on directories that containers need to write to, such as:
- `configs/code-server/.config` and `.local` (Code Server)
- `mkdocs/.cache` (MkDocs social cards plugin)
- `mkdocs/site` (MkDocs built output)
If the script can't fix some directories (owned by a different container UID), it will prompt to use `sudo`.
### Manual Permission Fix
If you prefer to fix manually:
```bash
# Fix all permissions at once
sudo chown -R $(id -u):$(id -g) .
chmod -R 755 .
# Or fix specific directories
chmod -R 777 configs/code-server/.config configs/code-server/.local
chmod -R 777 mkdocs/.cache mkdocs/site
```
AI tools were used to assist in the creation of this project. All generated code has been reviewed. Users should test all functionality to ensure it meets their requirements.

View File

@ -38,11 +38,19 @@ import ExcalidrawPage from '@/pages/ExcalidrawPage';
import SettingsPage from '@/pages/SettingsPage';
import PangolinPage from '@/pages/PangolinPage';
import ObservabilityPage from '@/pages/ObservabilityPage';
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
import SubscribersPage from '@/pages/payments/SubscribersPage';
import PaymentProductsPage from '@/pages/payments/ProductsPage';
import PaymentDonationsPage from '@/pages/payments/DonationsPage';
import PaymentSettingsPage from '@/pages/payments/PaymentSettingsPage';
import LibraryPage from '@/pages/media/LibraryPage';
import AnalyticsDashboardPage from '@/pages/media/AnalyticsDashboardPage';
import MediaJobsPage from '@/pages/media/MediaJobsPage';
import CommentModerationPage from '@/pages/media/CommentModerationPage';
import GalleryAdsPage from '@/pages/media/GalleryAdsPage';
import CampaignModerationPage from '@/pages/influence/CampaignModerationPage';
import CampaignEffectivenessPage from '@/pages/influence/CampaignEffectivenessPage';
import PublicLandingPage from '@/pages/public/LandingPage';
import CampaignsListPage from '@/pages/public/CampaignsListPage';
import CampaignPage from '@/pages/public/CampaignPage';
@ -59,6 +67,10 @@ import PlaylistViewerPage from '@/pages/public/PlaylistViewerPage';
import PlaylistManagementPage from '@/pages/media/PlaylistManagementPage';
import MyStatsPage from '@/pages/public/MyStatsPage';
import MySettingsPage from '@/pages/public/MySettingsPage';
import PricingPage from '@/pages/public/PricingPage';
import ShopPage from '@/pages/public/ShopPage';
import DonatePage from '@/pages/public/DonatePage';
import PaymentSuccessPage from '@/pages/public/PaymentSuccessPage';
import MyActivityPage from '@/pages/volunteer/MyActivityPage';
import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage';
import MyRoutesPage from '@/pages/volunteer/MyRoutesPage';
@ -70,7 +82,7 @@ import ResetPasswordPage from '@/pages/ResetPasswordPage';
function RoleAwareRedirect() {
const { user, isAuthenticated } = useAuthStore();
if (!isAuthenticated) return <Navigate to="/login" replace />;
if (!isAuthenticated) return <Navigate to="/campaigns" replace />;
if (user && isAdmin(user)) return <Navigate to="/app" replace />;
return <Navigate to="/volunteer" replace />;
}
@ -167,6 +179,20 @@ export default function App() {
<Route path="/map" element={<FeatureGate feature="enableMap"><MapPage /></FeatureGate>} />
<Route path="/p/:slug" element={<FeatureGate feature="enableLandingPages"><PublicLandingPage /></FeatureGate>} />
{/* Public Payment pages (PublicLayout, dark blue theme) — feature-gated */}
<Route path="/pricing" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
<Route index element={<PricingPage />} />
</Route>
<Route path="/shop" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
<Route index element={<ShopPage />} />
</Route>
<Route path="/donate" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
<Route index element={<DonatePage />} />
</Route>
<Route path="/payments/success" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
<Route index element={<PaymentSuccessPage />} />
</Route>
{/* Public Media Gallery (purple theme) — feature-gated */}
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
<Route index element={<MediaGalleryPage />} />
@ -285,6 +311,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="influence/effectiveness"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<CampaignEffectivenessPage />
</ProtectedRoute>
}
/>
<Route
path="listmonk"
element={
@ -317,6 +351,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="docs/analytics"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<DocsAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="code"
element={
@ -493,6 +535,54 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="media/gallery-ads"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<GalleryAdsPage />
</ProtectedRoute>
}
/>
<Route
path="payments"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<PaymentsDashboardPage />
</ProtectedRoute>
}
/>
<Route
path="payments/subscribers"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<SubscribersPage />
</ProtectedRoute>
}
/>
<Route
path="payments/products"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<PaymentProductsPage />
</ProtectedRoute>
}
/>
<Route
path="payments/donations"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<PaymentDonationsPage />
</ProtectedRoute>
}
/>
<Route
path="payments/settings"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<PaymentSettingsPage />
</ProtectedRoute>
}
/>
</Route>
<Route path="*" element={<RoleAwareRedirect />} />
</Routes>

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { Layout, Menu, Dropdown, Button, Typography, Drawer, Grid, theme } from 'antd';
import { Layout, Menu, Dropdown, Button, Typography, Drawer, Grid, theme, Tooltip, Modal, Badge } from 'antd';
import {
DashboardOutlined,
SendOutlined,
@ -15,6 +15,7 @@ import {
MenuFoldOutlined,
MenuUnfoldOutlined,
MenuOutlined,
HomeOutlined,
ScissorOutlined,
CalendarOutlined,
FileTextOutlined,
@ -35,10 +36,17 @@ import {
SoundOutlined,
EditOutlined,
OrderedListOutlined,
DollarOutlined,
ShoppingOutlined,
HeartOutlined,
CrownOutlined,
PictureOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import { hasAnyRole } from '@/utils/roles';
import type { PageHeaderConfig, AppOutletContext } from '@/types/api';
// Re-export for backward compatibility
@ -48,7 +56,7 @@ const { Header, Sider, Content } = Layout;
const { Text } = Typography;
const { useBreakpoint } = Grid;
function buildMenuItems(settings: import('@/types/api').SiteSettings | null): MenuProps['items'] {
function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isSuperAdmin: boolean, badges?: { pendingResponses?: number }): MenuProps['items'] {
const items: MenuProps['items'] = [
{
key: '/app',
@ -61,13 +69,14 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
items.push({
key: 'influence-submenu',
icon: <SendOutlined />,
label: 'Influence',
label: 'Advocacy',
children: [
{ key: '/app/campaigns', icon: <SendOutlined />, label: 'Campaigns' },
{ key: '/app/campaign-moderation', icon: <FileTextOutlined />, label: 'Campaign Review' },
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
{ key: '/app/email-queue', icon: <MailOutlined />, label: 'Email Queue' },
{ key: '/app/responses', icon: <MessageOutlined />, label: 'Responses' },
{ key: '/app/email-queue', icon: <MailOutlined />, label: 'Outgoing Emails' },
{ key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' },
{ key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
],
});
}
@ -78,7 +87,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
icon: <NotificationOutlined />,
label: 'Broadcast',
children: [
{ key: '/app/listmonk', icon: <MailOutlined />, label: 'Listmonk' },
{ key: '/app/listmonk', icon: <MailOutlined />, label: 'Newsletter' },
{ key: '/app/email-templates', icon: <FileTextOutlined />, label: 'Email Templates' },
],
});
@ -90,6 +99,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
webChildren.push({ key: '/app/pages', icon: <FileTextOutlined />, label: 'Landing Pages' });
}
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' });
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
items.push({
@ -108,7 +118,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
{ key: '/app/map', icon: <EnvironmentOutlined />, label: 'Locations' },
{ key: '/app/map/data-quality', icon: <BarChartOutlined />, label: 'Data Quality' },
{ key: '/app/map/shifts', icon: <CalendarOutlined />, label: 'Shifts' },
{ key: '/app/map/cuts', icon: <ScissorOutlined />, label: 'Cuts' },
{ key: '/app/map/cuts', icon: <ScissorOutlined />, label: 'Areas' },
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
{ key: '/app/map/settings', icon: <SettingOutlined />, label: 'Settings' },
],
@ -122,28 +132,51 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
label: 'Media Library',
children: [
{ key: '/app/media/library', icon: <FolderOutlined />, label: 'Videos' },
{ key: '/app/media/analytics', icon: <BarChartOutlined />, label: 'Analytics' },
{ key: '/app/media/curated', icon: <OrderedListOutlined />, label: 'Curated' },
{ key: '/app/media/moderation', icon: <MessageOutlined />, label: 'Moderation' },
{ key: '/app/media/gallery-ads', icon: <PictureOutlined />, label: 'Gallery Ads' },
{ key: '/app/media/jobs', icon: <HistoryOutlined />, label: 'Processing Jobs' },
],
});
}
items.push({
key: 'services-submenu',
icon: <CloudServerOutlined />,
label: 'Services',
children: [
{ key: '/app/services/nocodb', icon: <DatabaseOutlined />, label: 'Database' },
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
{ key: '/app/services/mailhog', icon: <MailOutlined />, label: 'MailHog' },
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
{ key: '/app/tunnel', icon: <CloudServerOutlined />, label: 'Tunnel' },
{ key: '/app/observability', icon: <LineChartOutlined />, label: 'Observability' },
],
});
if (settings?.enablePayments) {
items.push({
key: 'payments-submenu',
icon: <DollarOutlined />,
label: 'Payments',
children: [
{ key: '/app/payments', icon: <DashboardOutlined />, label: 'Dashboard' },
{ key: '/app/payments/subscribers', icon: <CrownOutlined />, label: 'Subscribers' },
{ key: '/app/payments/products', icon: <ShoppingOutlined />, label: 'Products' },
{ key: '/app/payments/donations', icon: <HeartOutlined />, label: 'Donations' },
{ key: '/app/payments/settings', icon: <SettingOutlined />, label: 'Settings' },
],
});
}
if (isSuperAdmin) {
items.push({
key: 'services-submenu',
icon: <CloudServerOutlined />,
label: 'Services',
children: [
{ type: 'group', label: 'Infrastructure', children: [
{ key: '/app/tunnel', icon: <CloudServerOutlined />, label: 'Tunnel' },
{ key: '/app/observability', icon: <LineChartOutlined />, label: 'Monitoring' },
{ key: '/app/services/nocodb', icon: <DatabaseOutlined />, label: 'Database' },
{ key: '/app/services/mailhog', icon: <MailOutlined />, label: 'MailHog' },
]},
{ type: 'group', label: 'Tools', children: [
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
]},
],
});
}
items.push(
{
@ -172,16 +205,39 @@ export default function AppLayout() {
const { token } = theme.useToken();
const screens = useBreakpoint();
const isMobile = !screens.md;
const menuItems = buildMenuItems(settings);
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
const [pendingResponses, setPendingResponses] = useState(0);
const fetchBadges = useCallback(() => {
api.get('/dashboard/summary').then(({ data }) => {
setPendingResponses(data?.responses?.pending ?? 0);
}).catch(() => {});
}, []);
useEffect(() => {
fetchBadges();
const interval = setInterval(fetchBadges, 120_000);
return () => clearInterval(interval);
}, [fetchBadges]);
const menuItems = buildMenuItems(settings, isSuperAdmin, { pendingResponses });
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
navigate(key);
if (isMobile) setDrawerOpen(false);
};
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
const handleLogout = () => {
Modal.confirm({
title: 'Log out?',
icon: <LogoutOutlined />,
okText: 'Log out',
okType: 'danger',
onOk: async () => {
await logout();
navigate('/login', { replace: true });
},
});
};
const userMenuItems: MenuProps['items'] = [
@ -219,6 +275,24 @@ export default function AppLayout() {
return best || '/app';
})();
// Derive which submenus should be open based on active route
const defaultOpenKeys = (() => {
const path = location.pathname;
const keys: string[] = [];
for (const item of menuItems || []) {
if (!item || !('children' in item) || !item.children) continue;
for (const child of item.children) {
if (!child || !('key' in child)) continue;
const k = child.key as string;
if (path === k || path.startsWith(k + '/')) {
keys.push(item.key as string);
break;
}
}
}
return keys;
})();
const fullBleed = pageHeader?.fullBleed === true;
const logoUrl = settings?.organizationLogoUrl;
@ -256,6 +330,7 @@ export default function AppLayout() {
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
defaultOpenKeys={defaultOpenKeys}
items={menuItems}
onClick={handleMenuClick}
/>
@ -291,7 +366,7 @@ export default function AppLayout() {
background: 'transparent',
display: 'flex',
alignItems: 'center',
gap: 12,
gap: 6,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
@ -313,38 +388,99 @@ export default function AppLayout() {
)}
<div style={{ flex: 1 }} />
{pageHeader?.actions}
<Button
type="text"
icon={<GlobalOutlined />}
onClick={() => window.open(`//${window.location.hostname}:4004`, '_blank')}
title="Open Static Site"
>
{!isMobile && 'Open Site'}
</Button>
<Button
type="text"
icon={<EnvironmentOutlined />}
onClick={() => navigate('/volunteer')}
title="Switch to Volunteer Portal"
>
{!isMobile && 'Canvass'}
</Button>
<Button
type="text"
icon={<VideoCameraOutlined />}
onClick={() => navigate('/gallery')}
title="Open Video Gallery"
>
{!isMobile && 'Video'}
</Button>
<Button
type="text"
icon={<SoundOutlined />}
onClick={() => navigate('/campaigns')}
title="View Public Campaigns"
>
{!isMobile && 'Campaigns'}
</Button>
<Tooltip title="Home (Static Site)">
<Button
type="text"
icon={<HomeOutlined />}
onClick={() => window.open(`//${window.location.hostname}:4004`, '_blank')}
>
{!isMobile && 'Home'}
</Button>
</Tooltip>
{settings?.enableInfluence !== false && (
<Tooltip title="View Public Campaigns">
<Button
type="text"
icon={<SoundOutlined />}
onClick={() => navigate('/campaigns')}
>
{!isMobile && 'Campaigns'}
</Button>
</Tooltip>
)}
{settings?.enableMap !== false && (
<>
<Tooltip title="View Public Map">
<Button
type="text"
icon={<EnvironmentOutlined />}
onClick={() => navigate('/map')}
>
{!isMobile && 'Map'}
</Button>
</Tooltip>
<Tooltip title="View Public Shifts">
<Button
type="text"
icon={<CalendarOutlined />}
onClick={() => navigate('/shifts')}
>
{!isMobile && 'Shifts'}
</Button>
</Tooltip>
<Tooltip title="Switch to Volunteer Portal">
<Button
type="text"
icon={<TeamOutlined />}
onClick={() => navigate('/volunteer')}
>
{!isMobile && 'Canvass'}
</Button>
</Tooltip>
</>
)}
{settings?.enableMediaFeatures !== false && (
<Tooltip title="Open Video Gallery">
<Button
type="text"
icon={<VideoCameraOutlined />}
onClick={() => navigate('/gallery')}
>
{!isMobile && 'Gallery'}
</Button>
</Tooltip>
)}
{settings?.enablePayments && (
<>
<Tooltip title="View Pricing Page">
<Button
type="text"
icon={<DollarOutlined />}
onClick={() => navigate('/pricing')}
>
{!isMobile && 'Pricing'}
</Button>
</Tooltip>
<Tooltip title="View Shop">
<Button
type="text"
icon={<ShoppingOutlined />}
onClick={() => navigate('/shop')}
>
{!isMobile && 'Shop'}
</Button>
</Tooltip>
<Tooltip title="View Donate Page">
<Button
type="text"
icon={<HeartOutlined />}
onClick={() => navigate('/donate')}
>
{!isMobile && 'Donate'}
</Button>
</Tooltip>
</>
)}
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" icon={<UserOutlined />}>
{!isMobile && (

View File

@ -1,15 +1,33 @@
import type { ReactNode } from 'react';
import { Result } from 'antd';
import { Result, Button } from 'antd';
import { useNavigate } from 'react-router-dom';
import { SettingOutlined } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import { useAuthStore } from '@/stores/auth.store';
import { hasAnyRole } from '@/utils/roles';
import type { SiteSettings } from '@/types/api';
const FEATURE_LABELS: Record<string, string> = {
enableInfluence: 'Influence (Advocacy Campaigns)',
enableMap: 'Map & Canvassing',
enableLandingPages: 'Landing Pages',
enableNewsletter: 'Newsletter',
enableMediaFeatures: 'Media Library',
enablePayments: 'Payments',
enableGalleryAds: 'Gallery Ads',
};
interface FeatureGateProps {
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures'>;
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds'>;
children: ReactNode;
}
export default function FeatureGate({ feature, children }: FeatureGateProps) {
const { settings, loading } = useSettingsStore();
const { user } = useAuthStore();
const navigate = useNavigate();
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
const featureName = FEATURE_LABELS[feature] || feature;
// While loading or if settings haven't arrived yet, render children (optimistic)
if (loading || !settings) return <>{children}</>;
@ -17,10 +35,22 @@ export default function FeatureGate({ feature, children }: FeatureGateProps) {
if (settings[feature] === false) {
return (
<Result
status="404"
title="Not Available"
subTitle="This feature is currently disabled."
status="info"
title="Feature Not Enabled"
subTitle={isSuperAdmin
? `${featureName} is currently disabled. You can enable it in Settings.`
: `${featureName} has not been enabled for this site.`
}
style={{ paddingTop: 80 }}
extra={isSuperAdmin && (
<Button
type="primary"
icon={<SettingOutlined />}
onClick={() => navigate('/app/settings', { state: { activeTab: 'features' } })}
>
Go to Settings
</Button>
)}
/>
);
}

View File

@ -286,6 +286,68 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
return `<section style="padding: 40px 20px;">${cardHtml}</section>`;
}
case 'donate-button': {
const heading = (defaults.heading as string) || 'Support Our Cause';
const description = (defaults.description as string) || 'Your contribution helps us create lasting change in our community.';
const buttonText = (defaults.buttonText as string) || 'Donate Now';
const showAmounts = defaults.showAmounts !== false;
return `
<section class="payment-block" data-payment-type="donate" data-button-text="${buttonText}" data-show-amounts="${showAmounts}" style="padding: 60px 40px; text-align: center; background: linear-gradient(135deg, #2d1b69 0%, #1a1a2e 100%); color: #fff;">
<div style="max-width: 600px; margin: 0 auto;">
<div style="font-size: 48px; margin-bottom: 16px;">&#x2764;</div>
<h2 style="font-size: 2rem; margin-bottom: 12px;">${heading}</h2>
<p style="font-size: 1.1rem; margin-bottom: 24px; opacity: 0.85;">${description}</p>
<a href="/donate" style="display: inline-block; padding: 14px 36px; background: #eb2f96; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 1.1rem;">${buttonText}</a>
</div>
</section>`;
}
case 'pricing-table': {
const heading = (defaults.heading as string) || 'Choose Your Plan';
const description = (defaults.description as string) || 'Get access to exclusive content and features.';
const showYearly = defaults.showYearly !== false;
return `
<section class="payment-block" data-payment-type="pricing" data-show-yearly="${showYearly}" style="padding: 60px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;">
<div style="max-width: 900px; margin: 0 auto;">
<div style="font-size: 48px; margin-bottom: 16px;">&#x1F451;</div>
<h2 style="font-size: 2rem; margin-bottom: 12px;">${heading}</h2>
<p style="font-size: 1.1rem; margin-bottom: 32px; opacity: 0.85;">${description}</p>
<div style="display: flex; gap: 24px; justify-content: center; flex-wrap: wrap;">
<div style="flex: 1; min-width: 220px; max-width: 280px; padding: 24px; background: rgba(255,255,255,0.08); border-radius: 12px;">
<h3 style="font-size: 1.25rem; margin-bottom: 8px;">Free</h3>
<p style="font-size: 2rem; font-weight: 700; margin: 8px 0;">$0</p>
<p style="opacity: 0.7; font-size: 0.9rem;">Access public content</p>
</div>
<div style="flex: 1; min-width: 220px; max-width: 280px; padding: 24px; background: rgba(114,46,209,0.2); border: 2px solid #722ed1; border-radius: 12px;">
<h3 style="font-size: 1.25rem; margin-bottom: 8px;">Premium</h3>
<p style="font-size: 2rem; font-weight: 700; margin: 8px 0;">$XX/mo</p>
<p style="opacity: 0.7; font-size: 0.9rem;">Plans load from API</p>
</div>
</div>
<p style="margin-top: 16px; font-size: 0.8rem; opacity: 0.5; font-style: italic;">Plans will load dynamically on published page</p>
</div>
</section>`;
}
case 'product-card': {
const productSlug = (defaults.productSlug as string) || '';
const buttonText = (defaults.buttonText as string) || 'Buy Now';
return `
<section class="payment-block" data-payment-type="product" data-product-slug="${productSlug}" data-button-text="${buttonText}" style="padding: 40px 20px;">
<div style="max-width: 380px; margin: 0 auto; border-radius: 12px; overflow: hidden; background: #1b2838; box-shadow: 0 4px 12px rgba(0,0,0,0.3);">
<div style="padding-bottom: 56.25%; background: linear-gradient(135deg, #9d4edd 0%, #722ed1 100%); position: relative;">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; color: #fff;">
<div style="font-size: 48px; margin-bottom: 8px;">&#x1F6D2;</div>
<p style="margin: 0; font-size: 14px; font-weight: 600;">Product Card</p>
<p style="margin: 4px 0 0; font-size: 12px; opacity: 0.7;">${productSlug || 'Set product slug'}</p>
</div>
</div>
<div style="padding: 16px;">
<div style="color: #fff; font-size: 15px; font-weight: 600;">${productSlug || 'Product Name'}</div>
<div style="color: #8899aa; font-size: 13px; margin-top: 6px;">Product details load on published page</div>
<a href="/shop" style="display: inline-block; margin-top: 12px; padding: 8px 20px; background: #9d4edd; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 0.9rem;">${buttonText}</a>
</div>
</div>
</section>`;
}
default:
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { ConfigProvider, Layout, Typography, theme, Space } from 'antd';
import { Outlet, Link, useNavigate } from 'react-router-dom';
import { PlayCircleOutlined, PlusCircleOutlined, FileTextOutlined, LoginOutlined, LogoutOutlined } from '@ant-design/icons';
import { ConfigProvider, Layout, Typography, theme, Space, Grid, Drawer, Button } from 'antd';
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
import { PlayCircleOutlined, LoginOutlined, LogoutOutlined, HeartOutlined, EnvironmentOutlined, CalendarOutlined, MenuOutlined, CloseOutlined, SendOutlined, HomeOutlined } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import { useAuthStore } from '@/stores/auth.store';
import AuthModal from '@/components/AuthModal';
@ -24,17 +24,39 @@ const navItemStyle: React.CSSProperties = {
font: 'inherit',
};
function NavLink({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) {
function NavLink({ to, icon, label, active }: { to: string; icon: React.ReactNode; label: string; active?: boolean }) {
return (
<Link
to={to}
style={{
...navItemStyle,
color: active ? '#fff' : 'rgba(255, 255, 255, 0.85)',
fontWeight: active ? 600 : undefined,
borderBottom: active ? '2px solid #fff' : '2px solid transparent',
paddingBottom: 2,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
{icon}
<span>{label}</span>
</Link>
);
}
function NavExternalLink({ href, icon, label }: { href: string; icon: React.ReactNode; label: string }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={navItemStyle}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
{icon}
<span>{label}</span>
</Link>
</a>
);
}
@ -44,7 +66,7 @@ function NavButton({ onClick, icon, label }: { onClick: () => void; icon: React.
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => { if (e.key === 'Enter') onClick(); }}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } }}
style={navItemStyle}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
@ -59,7 +81,25 @@ export default function PublicLayout() {
const { settings } = useSettingsStore();
const { isAuthenticated, logout } = useAuthStore();
const navigate = useNavigate();
const location = useLocation();
const [authModalOpen, setAuthModalOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
// Donate/payment pages show minimal nav (Home + Logout only)
const isDonateSection = location.pathname.startsWith('/donate') || location.pathname.startsWith('/payment');
// Active route detection for nav highlight
const activeRoute = (() => {
const p = location.pathname;
if (p.startsWith('/campaign')) return '/campaigns';
if (p.startsWith('/map')) return '/map';
if (p.startsWith('/shifts') || p.startsWith('/volunteer')) return '/shifts';
if (p.startsWith('/gallery')) return '/gallery';
if (p.startsWith('/donate') || p.startsWith('/pricing') || p.startsWith('/shop')) return '/donate';
return '';
})();
const colorPrimary = settings?.publicColorPrimary ?? '#3498db';
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
@ -127,21 +167,45 @@ export default function PublicLayout() {
</Link>
{/* Right: Navigation */}
<Space size={16} wrap>
{isAuthenticated ? (
<>
<NavLink to="/campaigns/create" icon={<PlusCircleOutlined />} label="Create Campaign" />
<NavLink to="/campaigns/mine" icon={<FileTextOutlined />} label="My Campaigns" />
{isMobile ? (
<Button
type="text"
icon={<MenuOutlined style={{ color: '#fff', fontSize: 20 }} />}
onClick={() => setDrawerOpen(true)}
aria-label="Open navigation menu"
style={{ padding: '4px 8px' }}
/>
) : (
<Space size={16}>
<NavExternalLink href={`//${window.location.hostname}:4004`} icon={<HomeOutlined />} label="Home" />
{isDonateSection ? (
<></>
) : (
<>
{settings?.enableInfluence !== false && (
<NavLink to="/campaigns" icon={<SendOutlined />} label="Campaigns" active={activeRoute === '/campaigns'} />
)}
{settings?.enableMap !== false && (
<>
<NavLink to="/map" icon={<EnvironmentOutlined />} label="Map" active={activeRoute === '/map'} />
<NavLink to="/shifts" icon={<CalendarOutlined />} label="Shifts" active={activeRoute === '/shifts'} />
</>
)}
{settings?.enableMediaFeatures !== false && (
<NavLink to="/gallery" icon={<PlayCircleOutlined />} label="Gallery" active={activeRoute === '/gallery'} />
)}
{settings?.enablePayments && (
<NavLink to="/donate" icon={<HeartOutlined />} label="Donate" active={activeRoute === '/donate'} />
)}
</>
)}
{isAuthenticated ? (
<NavButton onClick={() => logout()} icon={<LogoutOutlined />} label="Logout" />
</>
) : (
<>
<NavButton onClick={() => setAuthModalOpen(true)} icon={<PlusCircleOutlined />} label="Create Campaign" />
) : (
<NavButton onClick={() => setAuthModalOpen(true)} icon={<LoginOutlined />} label="Sign In" />
</>
)}
<NavLink to="/gallery" icon={<PlayCircleOutlined />} label="Media Gallery" />
</Space>
)}
</Space>
)}
</Header>
<Content
style={{
@ -164,20 +228,113 @@ export default function PublicLayout() {
>
<div>{footerText}</div>
<div style={{ marginTop: 8 }}>
<Link to="/campaigns" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
Campaigns
</Link>
{' • '}
<Link to="/campaigns/create" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
Create Campaign
</Link>
{' • '}
<Link to="/gallery" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
Media Gallery
</Link>
{settings?.enableInfluence !== false && (
<Link to="/campaigns" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Campaigns</Link>
)}
{settings?.enableMap !== false && (
<>
{' • '}
<Link to="/map" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Map</Link>
{' • '}
<Link to="/shifts" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Shifts</Link>
</>
)}
{settings?.enableMediaFeatures !== false && (
<>
{' • '}
<Link to="/gallery" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Gallery</Link>
</>
)}
{settings?.enablePayments && (
<>
{' • '}
<Link to="/donate" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Donate</Link>
</>
)}
</div>
</Footer>
{/* Mobile Navigation Drawer */}
<Drawer
title={orgName}
placement="right"
onClose={() => setDrawerOpen(false)}
open={drawerOpen}
width={280}
closeIcon={<CloseOutlined style={{ color: 'rgba(255,255,255,0.85)' }} />}
styles={{
header: { background: colorBgContainer, borderBottom: '1px solid rgba(255,255,255,0.1)' },
body: { background: colorBgBase, padding: '16px 0' },
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<a
href={`//${window.location.hostname}:4004`}
target="_blank"
rel="noopener noreferrer"
onClick={() => setDrawerOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 24px',
color: 'rgba(255,255,255,0.85)',
textDecoration: 'none', fontSize: 15,
borderRadius: 4,
}}
>
<HomeOutlined />
<span>Home</span>
</a>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', margin: '4px 24px' }} />
{[
{ to: '/campaigns', icon: <SendOutlined />, label: 'Campaigns', show: settings?.enableInfluence !== false },
{ to: '/map', icon: <EnvironmentOutlined />, label: 'Map', show: settings?.enableMap !== false },
{ to: '/shifts', icon: <CalendarOutlined />, label: 'Shifts', show: settings?.enableMap !== false },
{ to: '/gallery', icon: <PlayCircleOutlined />, label: 'Gallery', show: settings?.enableMediaFeatures !== false },
{ to: '/donate', icon: <HeartOutlined />, label: 'Donate', show: !!settings?.enablePayments },
].filter(item => item.show).map(item => (
<Link
key={item.to}
to={item.to}
onClick={() => setDrawerOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 24px',
color: activeRoute === item.to ? '#fff' : 'rgba(255,255,255,0.85)',
textDecoration: 'none', fontSize: 15,
fontWeight: activeRoute === item.to ? 600 : 400,
background: activeRoute === item.to ? 'rgba(255,255,255,0.1)' : 'transparent',
borderRadius: 4,
}}
>
{item.icon}
<span>{item.label}</span>
</Link>
))}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', margin: '8px 24px' }} />
{isAuthenticated ? (
<span
role="button"
tabIndex={0}
onClick={() => { logout(); setDrawerOpen(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { logout(); setDrawerOpen(false); } }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }}
>
<LogoutOutlined /> <span>Logout</span>
</span>
) : (
<span
role="button"
tabIndex={0}
onClick={() => { setAuthModalOpen(true); setDrawerOpen(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { setAuthModalOpen(true); setDrawerOpen(false); } }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }}
>
<LoginOutlined /> <span>Sign In</span>
</span>
)}
</div>
</Drawer>
<AuthModal
open={authModalOpen}
onCancel={() => setAuthModalOpen(false)}

View File

@ -1,6 +1,6 @@
import { useNavigate, Outlet } from 'react-router-dom';
import { ConfigProvider, Layout, Button, Typography, Dropdown, theme } from 'antd';
import { LogoutOutlined, UserOutlined } from '@ant-design/icons';
import { LogoutOutlined, UserOutlined, GlobalOutlined, HomeOutlined } from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
@ -21,6 +21,9 @@ export default function VolunteerLayout() {
};
const userMenuItems: MenuProps['items'] = [
{ key: 'home', icon: <HomeOutlined />, label: 'Home', onClick: () => window.open(`//${window.location.hostname}:4004`, '_blank') },
{ key: 'browse', icon: <GlobalOutlined />, label: 'Browse Site', onClick: () => navigate('/campaigns') },
{ type: 'divider' },
{ key: 'logout', icon: <LogoutOutlined />, label: 'Logout', onClick: handleLogout },
];

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Button, Badge, Drawer } from 'antd';
import { useState, useRef } from 'react';
import { Button, Badge, Drawer, Tooltip } from 'antd';
import {
ArrowRightOutlined,
NodeIndexOutlined,
@ -77,6 +77,7 @@ export default function BottomControlPanel({
const [showLegend, setShowLegend] = useState(false);
const [showTiles, setShowTiles] = useState(false);
const [showDocs, setShowDocs] = useState(false);
const globeBtnRef = useRef<HTMLDivElement>(null);
// Compact icon button with scale feedback
const IconButton = ({
@ -97,30 +98,32 @@ export default function BottomControlPanel({
const [pressed, setPressed] = useState(false);
const button = (
<Button
type={type}
icon={icon}
onClick={onClick}
size="middle"
ghost={ghost}
aria-label={label}
onTouchStart={() => setPressed(true)}
onTouchEnd={() => setPressed(false)}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
onMouseLeave={() => setPressed(false)}
style={{
transform: pressed ? 'scale(0.92)' : 'scale(1)',
transition: 'transform 0.08s ease',
minWidth: 36,
width: 36,
height: 36,
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
<Tooltip title={label} mouseEnterDelay={0.5}>
<Button
type={type}
icon={icon}
onClick={onClick}
size="middle"
ghost={ghost}
aria-label={label}
onTouchStart={() => setPressed(true)}
onTouchEnd={() => setPressed(false)}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
onMouseLeave={() => setPressed(false)}
style={{
transform: pressed ? 'scale(0.92)' : 'scale(1)',
transition: 'transform 0.08s ease',
minWidth: 36,
width: 36,
height: 36,
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
</Tooltip>
);
return badge ? (
@ -218,12 +221,14 @@ export default function BottomControlPanel({
{/* Center group: Utility buttons */}
<div style={{ display: 'flex', gap: 4, flex: sessionActive ? 0 : 1, justifyContent: sessionActive ? 'flex-start' : 'center' }}>
<IconButton
icon={<GlobalOutlined />}
onClick={() => { setShowTiles(!showTiles); setShowSearch(false); setShowCuts(false); setShowLegend(false); setShowDocs(false); }}
label="Map tiles"
type={showTiles ? 'primary' : 'default'}
/>
<div ref={globeBtnRef} style={{ display: 'inline-flex' }}>
<IconButton
icon={<GlobalOutlined />}
onClick={() => { setShowTiles(!showTiles); setShowSearch(false); setShowCuts(false); setShowLegend(false); setShowDocs(false); }}
label="Map tiles"
type={showTiles ? 'primary' : 'default'}
/>
</div>
<IconButton
icon={<SearchOutlined />}
onClick={() => { setShowSearch(!showSearch); setShowTiles(false); setShowCuts(false); setShowLegend(false); setShowDocs(false); }}
@ -316,8 +321,7 @@ export default function BottomControlPanel({
style={{
position: 'absolute',
bottom: bottomOffset + 44 + 8, // Just above control bar (44px bar height + 8px gap)
left: '50%',
transform: 'translateX(-50%)',
left: globeBtnRef.current ? globeBtnRef.current.getBoundingClientRect().left : 8,
zIndex: 1090,
display: 'flex',
flexDirection: 'column',
@ -331,7 +335,8 @@ export default function BottomControlPanel({
onTileChange(layer.key);
setShowTiles(false);
}}
title={layer.label}
aria-label={layer.label}
aria-pressed={activeTileKey === layer.key}
style={{
width: 36,
height: 36,

View File

@ -37,7 +37,11 @@ export default function GPSTracker({ following, onPositionChange }: GPSTrackerPr
map.setView(latlng, map.getZoom(), { animate: true });
}
},
() => {},
(error) => {
if (error.code === error.PERMISSION_DENIED) {
console.warn('GPS permission denied — enable location access in your browser settings');
}
},
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 10000 },
);

View File

@ -0,0 +1,58 @@
import { Modal, Select, message } from 'antd';
import { useState } from 'react';
import { mediaApi } from '@/lib/media-api';
interface BulkAccessLevelModalProps {
open: boolean;
videoIds: number[];
onClose: () => void;
onSuccess: () => void;
}
const ACCESS_LEVEL_OPTIONS = [
{ value: 'free', label: 'Free — Anyone can view' },
{ value: 'member', label: 'Member — Any active subscriber' },
{ value: 'premium', label: 'Premium — Premium subscribers only' },
];
export default function BulkAccessLevelModal({ open, videoIds, onClose, onSuccess }: BulkAccessLevelModalProps) {
const [accessLevel, setAccessLevel] = useState<string>('free');
const [loading, setLoading] = useState(false);
const handleOk = async () => {
setLoading(true);
try {
const { data } = await mediaApi.post<{ updatedCount: number }>('/videos/bulk-access-level', {
videoIds,
accessLevel,
});
message.success(`Updated access level to "${accessLevel}" for ${data.updatedCount} video(s)`);
onSuccess();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to update access levels');
} finally {
setLoading(false);
}
};
return (
<Modal
title={`Set Access Level (${videoIds.length} video${videoIds.length !== 1 ? 's' : ''})`}
open={open}
onOk={handleOk}
onCancel={onClose}
confirmLoading={loading}
okText="Apply"
>
<div style={{ marginTop: 16 }}>
<Select
value={accessLevel}
onChange={setAccessLevel}
options={ACCESS_LEVEL_OPTIONS}
style={{ width: '100%' }}
size="large"
/>
</div>
</Modal>
);
}

View File

@ -19,6 +19,12 @@ const CATEGORY_OPTIONS = [
{ value: 'highlights', label: 'Highlights' },
];
const ACCESS_LEVEL_OPTIONS = [
{ value: 'free', label: 'Free — Anyone can view' },
{ value: 'member', label: 'Member — Any active subscriber' },
{ value: 'premium', label: 'Premium — Premium subscribers only' },
];
export default function EditVideoModal({ video, open, onClose, onSuccess }: EditVideoDrawerProps) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
@ -40,6 +46,7 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
tags: Array.isArray(v.tags) ? v.tags : [],
quality: v.quality || '',
isShort: v.isShort ?? false,
accessLevel: v.accessLevel || 'free',
});
})
.catch(() => {
@ -52,6 +59,7 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
tags: Array.isArray(video.tags) ? video.tags : [],
quality: video.quality || '',
isShort: video.isShort ?? false,
accessLevel: video.accessLevel || 'free',
});
})
.finally(() => setFetching(false));
@ -73,6 +81,7 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
payload.category = values.category || null;
payload.tags = values.tags && values.tags.length > 0 ? values.tags : null;
if (values.isShort !== undefined) payload.isShort = values.isShort;
if (values.accessLevel) payload.accessLevel = values.accessLevel;
await mediaApi.patch(`/videos/${video.id}`, payload);
message.success('Video updated successfully');
@ -139,6 +148,10 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
/>
</Form.Item>
<Form.Item name="accessLevel" label="Access Level">
<Select options={ACCESS_LEVEL_OPTIONS} />
</Form.Item>
<Form.Item name="isShort" label="Short Video" valuePropName="checked">
<Switch checkedChildren="Yes" unCheckedChildren="No" />
</Form.Item>

View File

@ -0,0 +1,234 @@
import { useEffect, useRef, useCallback } from 'react';
import { Button, Typography, theme } from 'antd';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import { getOrCreateSessionId } from '@/lib/media-public-api';
import type { GalleryAd } from '@/types/gallery-ads';
const { Title, Text } = Typography;
interface GalleryAdCardProps {
ad: GalleryAd;
/** When true, disables tracking and click navigation (for admin preview) */
preview?: boolean;
}
export default function GalleryAdCard({ ad, preview }: GalleryAdCardProps) {
const { token } = theme.useToken();
const navigate = useNavigate();
const cardRef = useRef<HTMLDivElement>(null);
const impressionSent = useRef(false);
// IntersectionObserver for impression tracking: fires when 50%+ visible for 1s
useEffect(() => {
if (preview) return; // No tracking in preview mode
const el = cardRef.current;
if (!el || impressionSent.current) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry?.isIntersecting) {
timer = setTimeout(() => {
if (!impressionSent.current) {
impressionSent.current = true;
trackEvent('impression');
}
}, 1000);
} else if (timer) {
clearTimeout(timer);
timer = null;
}
},
{ threshold: 0.5 }
);
observer.observe(el);
return () => {
observer.disconnect();
if (timer) clearTimeout(timer);
};
}, [ad.id, preview]);
const trackEvent = useCallback(
(event: 'impression' | 'click') => {
if (preview) return;
axios
.post('/api/gallery-ads/track', {
adId: ad.id,
event,
sessionId: getOrCreateSessionId(),
})
.catch(() => {}); // silent fail
},
[ad.id, preview]
);
const handleClick = () => {
if (preview) return; // No navigation in preview mode
trackEvent('click');
if (ad.linkUrl) {
if (ad.linkUrl.startsWith('http')) {
window.open(ad.linkUrl, '_blank', 'noopener');
} else {
navigate(ad.linkUrl);
}
}
};
// Variant-specific styles
const isHighlight = ad.variant === 'highlight';
const isMinimal = ad.variant === 'minimal';
const bgGradient = ad.bgColor
? `linear-gradient(135deg, ${ad.bgColor} 0%, ${adjustColor(ad.bgColor, -30)} 100%)`
: isHighlight
? `linear-gradient(135deg, ${token.colorPrimary} 0%, ${adjustColor(token.colorPrimary, -40)} 100%)`
: `linear-gradient(135deg, ${token.colorBgElevated} 0%, ${token.colorBgContainer} 100%)`;
const borderStyle = isHighlight
? `2px solid ${ad.bgColor || token.colorPrimary}`
: `1px solid ${token.colorBorderSecondary}`;
return (
<div
ref={cardRef}
style={{
borderRadius: 12,
overflow: 'hidden',
border: borderStyle,
cursor: preview ? 'default' : ad.linkUrl ? 'pointer' : 'default',
transition: 'all 0.2s ease',
display: 'flex',
flexDirection: 'column',
...(isHighlight && {
boxShadow: `0 0 20px ${(ad.bgColor || token.colorPrimary)}33`,
}),
}}
onClick={handleClick}
>
{/* Top section: visual area matching video card 16:9 ratio */}
{!isMinimal && (
<div
style={{
position: 'relative',
paddingTop: '56.25%', // 16:9
background: ad.imagePath ? `url(${ad.imagePath}) center/cover no-repeat` : bgGradient,
}}
>
{/* Overlay content centered in the 16:9 space */}
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 20,
textAlign: 'center',
...(ad.imagePath && {
background: 'rgba(0, 0, 0, 0.5)',
}),
}}
>
{ad.iconEmoji && (
<span style={{ fontSize: 36, marginBottom: 8 }}>{ad.iconEmoji}</span>
)}
<Title
level={4}
style={{
color: '#fff',
margin: 0,
textShadow: '0 1px 3px rgba(0,0,0,0.3)',
}}
>
{ad.title}
</Title>
{ad.subtitle && (
<Text
style={{
color: 'rgba(255,255,255,0.85)',
marginTop: 8,
fontSize: 13,
maxWidth: 240,
}}
>
{ad.subtitle}
</Text>
)}
</div>
</div>
)}
{/* Bottom section: CTA + promoted label */}
<div
style={{
padding: isMinimal ? '20px 16px' : '12px 16px',
background: isMinimal ? bgGradient : token.colorBgContainer,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{isMinimal && (
<>
{ad.iconEmoji && (
<span style={{ fontSize: 24 }}>{ad.iconEmoji}</span>
)}
<Title
level={5}
style={{ color: token.colorText, margin: 0 }}
>
{ad.title}
</Title>
{ad.subtitle && (
<Text type="secondary" style={{ fontSize: 12 }}>
{ad.subtitle}
</Text>
)}
</>
)}
{ad.ctaText && (
<Button
type={ad.ctaStyle === 'primary' ? 'primary' : ad.ctaStyle === 'outline' ? 'default' : 'link'}
size="small"
block={!isMinimal}
onClick={(e) => {
e.stopPropagation();
handleClick();
}}
>
{ad.ctaText}
</Button>
)}
<Text
style={{
fontSize: 10,
color: token.colorTextQuaternary,
textAlign: 'center',
}}
>
Promoted
</Text>
</div>
</div>
);
}
/** Lighten/darken a hex color by an amount (-255 to 255) */
function adjustColor(hex: string, amount: number): string {
const clamp = (v: number) => Math.max(0, Math.min(255, v));
const h = hex.replace('#', '');
const r = clamp(parseInt(h.substring(0, 2), 16) + amount);
const g = clamp(parseInt(h.substring(2, 4), 16) + amount);
const b = clamp(parseInt(h.substring(4, 6), 16) + amount);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}

View File

@ -18,6 +18,7 @@ import {
MenuUnfoldOutlined,
DownOutlined,
RightOutlined,
ArrowLeftOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store';
import { hexToRgba } from '@/utils/color';
@ -225,6 +226,57 @@ export default function MediaSidebar() {
overflowX: 'hidden',
}}
>
{/* Home + Back to Site links */}
<div style={{ padding: collapsed ? '8px 0' : '8px 12px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<Tooltip title={collapsed ? 'Home' : ''} placement="right">
<a
href={`//${window.location.hostname}:4004`}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: collapsed ? '8px 0' : '8px 12px',
cursor: 'pointer',
borderRadius: 8,
color: 'rgba(255,255,255,0.65)',
transition: 'all 0.2s ease',
justifyContent: collapsed ? 'center' : 'flex-start',
fontSize: 13,
textDecoration: 'none',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = hoverBg; e.currentTarget.style.color = 'rgba(255,255,255,0.85)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'rgba(255,255,255,0.65)'; }}
>
<HomeOutlined style={{ fontSize: 14 }} />
{!collapsed && <Text style={{ fontSize: 13, color: 'inherit' }}>Home</Text>}
</a>
</Tooltip>
<Tooltip title={collapsed ? 'Back to Site' : ''} placement="right">
<div
onClick={() => navigate('/campaigns')}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: collapsed ? '8px 0' : '8px 12px',
cursor: 'pointer',
borderRadius: 8,
color: 'rgba(255,255,255,0.65)',
transition: 'all 0.2s ease',
justifyContent: collapsed ? 'center' : 'flex-start',
fontSize: 13,
}}
onMouseEnter={(e) => { e.currentTarget.style.background = hoverBg; e.currentTarget.style.color = 'rgba(255,255,255,0.85)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'rgba(255,255,255,0.65)'; }}
>
<ArrowLeftOutlined style={{ fontSize: 14 }} />
{!collapsed && <Text style={{ fontSize: 13, color: 'inherit' }}>Back to Site</Text>}
</div>
</Tooltip>
</div>
{/* Content Navigation Section */}
<div style={{ padding: collapsed ? '12px 0' : '12px' }}>
{/* Section header */}
@ -641,7 +693,7 @@ export default function MediaSidebar() {
// Sign In button
<Tooltip title={collapsed ? 'Sign In' : ''} placement="right">
<div
onClick={() => navigate('/auth/login')}
onClick={() => navigate('/login')}
style={{
display: 'flex',
alignItems: 'center',

View File

@ -1,5 +1,5 @@
import { Card, Checkbox, Tag, Spin } from 'antd';
import { ClockCircleOutlined, CheckCircleOutlined, ThunderboltFilled } from '@ant-design/icons';
import { ClockCircleOutlined, CheckCircleOutlined, ThunderboltFilled, LockOutlined, CrownOutlined } from '@ant-design/icons';
import { useState } from 'react';
import type { Video } from '@/types/media';
import { getAuthCallbacks } from '@/lib/api';
@ -284,6 +284,15 @@ export default function VideoCard({
<div style={{ fontSize: 12 }}>
<div>{video.width} × {video.height}</div>
<div>{formatFileSize(video.fileSize)}</div>
{video.accessLevel && video.accessLevel !== 'free' && (
<Tag
style={{ marginTop: 4 }}
color={video.accessLevel === 'premium' ? 'gold' : 'purple'}
icon={video.accessLevel === 'premium' ? <CrownOutlined /> : <LockOutlined />}
>
{video.accessLevel === 'premium' ? 'Premium' : 'Member'}
</Tag>
)}
{video.producer && (
<Tag style={{ marginTop: 4 }} color="blue">
{video.producer}

View File

@ -0,0 +1,179 @@
import { useState, useEffect } from 'react';
import { Modal, Radio, InputNumber, Spin, Typography, Space, theme } from 'antd';
import { HeartOutlined, DollarOutlined, AppstoreOutlined } from '@ant-design/icons';
import axios from 'axios';
const { Text, Paragraph } = Typography;
interface PaymentConfig {
enableDonations: boolean;
donationSuggestedAmounts: number[];
donationMinimum: number;
donationPageTitle: string;
donationPageDescription: string | null;
}
export type DonateVariant = 'simple' | 'set-amount' | 'full';
export interface DonateInsertResult {
variant: DonateVariant;
amount?: number; // cents, for set-amount variant
config?: PaymentConfig; // for full variant
}
interface DonateInsertModalProps {
open: boolean;
onClose: () => void;
onInsert: (result: DonateInsertResult) => void;
}
export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModalProps) {
const [variant, setVariant] = useState<DonateVariant>('simple');
const [amount, setAmount] = useState<number | null>(25);
const [config, setConfig] = useState<PaymentConfig | null>(null);
const [configLoading, setConfigLoading] = useState(false);
const [configError, setConfigError] = useState<string | null>(null);
const { token } = theme.useToken();
// Fetch payment config when "full" variant is selected
useEffect(() => {
if (variant === 'full' && !config && open) {
setConfigLoading(true);
setConfigError(null);
axios.get('/api/payments/config')
.then(({ data }) => setConfig(data))
.catch(() => setConfigError('Failed to load donation settings. The full card will use default values.'))
.finally(() => setConfigLoading(false));
}
}, [variant, config, open]);
// Reset when modal opens
useEffect(() => {
if (open) {
setVariant('simple');
setAmount(25);
setConfigError(null);
}
}, [open]);
const handleOk = () => {
const result: DonateInsertResult = { variant };
if (variant === 'set-amount' && amount) {
result.amount = Math.round(amount * 100); // dollars to cents
}
if (variant === 'full') {
result.config = config || undefined;
}
onInsert(result);
onClose();
};
const cardStyle = (selected: boolean) => ({
padding: '16px',
borderRadius: 8,
border: `2px solid ${selected ? token.colorPrimary : token.colorBorderSecondary}`,
background: selected ? token.colorPrimaryBg : token.colorBgContainer,
cursor: 'pointer',
transition: 'all 0.2s',
});
return (
<Modal
title="Insert Donate Block"
open={open}
onCancel={onClose}
onOk={handleOk}
okText="Insert"
okButtonProps={{ disabled: variant === 'set-amount' && (!amount || amount <= 0) }}
width={520}
>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
Choose a donation block style to insert into your document.
</Paragraph>
<Radio.Group
value={variant}
onChange={(e) => setVariant(e.target.value)}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
{/* Simple CTA */}
<div style={cardStyle(variant === 'simple')} onClick={() => setVariant('simple')}>
<Radio value="simple">
<Space>
<HeartOutlined style={{ fontSize: 18, color: '#eb2f96' }} />
<div>
<Text strong>Simple CTA Button</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
A styled button linking to the donate page
</Text>
</div>
</Space>
</Radio>
</div>
{/* Set Amount */}
<div style={cardStyle(variant === 'set-amount')} onClick={() => setVariant('set-amount')}>
<Radio value="set-amount">
<Space>
<DollarOutlined style={{ fontSize: 18, color: '#52c41a' }} />
<div>
<Text strong>Set Amount Card</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
A card pre-configured for a specific dollar amount
</Text>
</div>
</Space>
</Radio>
{variant === 'set-amount' && (
<div style={{ marginTop: 12, marginLeft: 32 }}>
<Text style={{ marginRight: 8 }}>Amount:</Text>
<InputNumber
prefix="$"
min={1}
max={100000}
value={amount}
onChange={(v) => setAmount(v)}
style={{ width: 140 }}
size="small"
/>
</div>
)}
</div>
{/* Full Standard */}
<div style={cardStyle(variant === 'full')} onClick={() => setVariant('full')}>
<Radio value="full">
<Space>
<AppstoreOutlined style={{ fontSize: 18, color: '#722ed1' }} />
<div>
<Text strong>Full Donate Card</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
Shows your configured title, description, and suggested amounts
</Text>
</div>
</Space>
</Radio>
{variant === 'full' && (
<div style={{ marginTop: 12, marginLeft: 32 }}>
{configLoading && <Spin size="small" />}
{configError && <Text type="warning" style={{ fontSize: 12 }}>{configError}</Text>}
{config && (
<div style={{ fontSize: 12 }}>
<Text type="secondary">Title: </Text><Text>{config.donationPageTitle}</Text>
<br />
<Text type="secondary">Amounts: </Text>
<Text>{config.donationSuggestedAmounts.map(a => `$${(a / 100).toFixed(0)}`).join(', ')}</Text>
</div>
)}
</div>
)}
</div>
</Space>
</Radio.Group>
</Modal>
);
}

View File

@ -0,0 +1,230 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
interface PaymentConfig {
enableDonations: boolean;
donationSuggestedAmounts: number[];
donationMinimum: number;
donationPageTitle: string;
donationPageDescription: string | null;
}
interface DonationWidgetProps {
buttonText?: string;
showAmounts?: boolean;
}
export function DonationWidget({ buttonText = 'Donate Now', showAmounts = true }: DonationWidgetProps) {
const [config, setConfig] = useState<PaymentConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
const [customAmount, setCustomAmount] = useState('');
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [isAnonymous, setIsAnonymous] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
useEffect(() => {
axios.get('/api/payments/config')
.then(({ data }) => {
setConfig(data);
if (data.donationSuggestedAmounts?.length > 0) {
setSelectedAmount(data.donationSuggestedAmounts[0]);
}
})
.catch(() => setError('Failed to load donation settings'))
.finally(() => setLoading(false));
}, []);
const handleDonate = async () => {
if (!email) {
setSubmitError('Email is required');
return;
}
const amountCents = selectedAmount || (customAmount ? Math.round(parseFloat(customAmount) * 100) : 0);
const minimum = config?.donationMinimum || 500;
if (!amountCents || amountCents < minimum) {
setSubmitError(`Minimum donation is $${(minimum / 100).toFixed(2)}`);
return;
}
setSubmitting(true);
setSubmitError(null);
try {
const { data } = await axios.post('/api/payments/donate', {
amountCents,
email,
name: name || undefined,
message: message || undefined,
isAnonymous,
});
if (data.url) {
window.location.href = data.url;
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to start donation';
setSubmitError(msg);
} finally {
setSubmitting(false);
}
};
const styles = {
container: { maxWidth: 500, margin: '0 auto', padding: '24px', color: '#fff' } as React.CSSProperties,
card: { background: 'rgba(255,255,255,0.08)', borderRadius: 12, padding: 24 } as React.CSSProperties,
amountBtn: (active: boolean) => ({
display: 'inline-block',
padding: '10px 20px',
margin: '4px',
background: active ? '#eb2f96' : 'rgba(255,255,255,0.1)',
color: '#fff',
border: active ? '2px solid #eb2f96' : '2px solid rgba(255,255,255,0.2)',
borderRadius: 8,
cursor: 'pointer',
fontWeight: 600,
fontSize: '1rem',
} as React.CSSProperties),
input: {
width: '100%',
padding: '10px 14px',
background: 'rgba(255,255,255,0.08)',
border: '1px solid rgba(255,255,255,0.2)',
borderRadius: 8,
color: '#fff',
fontSize: '0.95rem',
boxSizing: 'border-box' as const,
marginBottom: 12,
} as React.CSSProperties,
label: { display: 'block', marginBottom: 4, fontSize: '0.85rem', opacity: 0.8 } as React.CSSProperties,
submitBtn: {
width: '100%',
padding: '14px 24px',
background: '#eb2f96',
color: '#fff',
border: 'none',
borderRadius: 8,
fontWeight: 600,
fontSize: '1.05rem',
cursor: 'pointer',
opacity: submitting ? 0.7 : 1,
} as React.CSSProperties,
error: { color: '#ff4d4f', fontSize: '0.9rem', marginBottom: 12 } as React.CSSProperties,
checkboxRow: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, fontSize: '0.9rem' } as React.CSSProperties,
};
if (loading) {
return <div style={{ ...styles.container, textAlign: 'center' }}>Loading...</div>;
}
if (error || !config?.enableDonations) {
return (
<div style={{ ...styles.container, textAlign: 'center' }}>
<p style={{ opacity: 0.7 }}>{error || 'Donations are currently disabled'}</p>
<a href="/donate" style={{ color: '#eb2f96' }}>Go to donation page</a>
</div>
);
}
const suggestedAmounts = config.donationSuggestedAmounts || [];
return (
<div style={styles.container}>
<div style={styles.card}>
{showAmounts && suggestedAmounts.length > 0 && (
<div style={{ marginBottom: 20, textAlign: 'center' }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>Select an amount</div>
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center' }}>
{suggestedAmounts.map((amt) => (
<button
key={amt}
type="button"
style={styles.amountBtn(selectedAmount === amt)}
onClick={() => { setSelectedAmount(amt); setCustomAmount(''); }}
>
${(amt / 100).toFixed(0)}
</button>
))}
<button
type="button"
style={styles.amountBtn(!selectedAmount)}
onClick={() => setSelectedAmount(null)}
>
Custom
</button>
</div>
</div>
)}
{(!selectedAmount || !showAmounts) && (
<div style={{ marginBottom: 12 }}>
<label style={styles.label}>Amount ($)</label>
<input
type="number"
min={(config.donationMinimum || 500) / 100}
step="1"
value={customAmount}
onChange={(e) => { setCustomAmount(e.target.value); setSelectedAmount(null); }}
placeholder={`Minimum $${((config.donationMinimum || 500) / 100).toFixed(2)}`}
style={styles.input}
/>
</div>
)}
<div>
<label style={styles.label}>Email *</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
style={styles.input}
/>
</div>
<div>
<label style={styles.label}>Name (optional)</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
style={styles.input}
/>
</div>
<div>
<label style={styles.label}>Message (optional)</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
style={{ ...styles.input, resize: 'vertical' }}
/>
</div>
<div style={styles.checkboxRow}>
<input
type="checkbox"
checked={isAnonymous}
onChange={(e) => setIsAnonymous(e.target.checked)}
id="donate-anon"
/>
<label htmlFor="donate-anon">Make my donation anonymous</label>
</div>
{submitError && <div style={styles.error}>{submitError}</div>}
<button
type="button"
style={styles.submitBtn}
onClick={handleDonate}
disabled={submitting}
>
{submitting ? 'Processing...' : `${buttonText} ${selectedAmount ? `$${(selectedAmount / 100).toFixed(0)}` : customAmount ? `$${parseFloat(customAmount).toFixed(2)}` : ''}`}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,183 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import type { SubscriptionPlan } from '@/types/api';
interface PricingWidgetProps {
showYearlyToggle?: boolean;
}
export function PricingWidget({ showYearlyToggle = true }: PricingWidgetProps) {
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [yearly, setYearly] = useState(false);
const [subscribingId, setSubscribingId] = useState<number | null>(null);
const [subError, setSubError] = useState<string | null>(null);
useEffect(() => {
axios.get('/api/payments/plans')
.then(({ data }) => setPlans(data))
.catch(() => setError('Failed to load plans'))
.finally(() => setLoading(false));
}, []);
const handleSubscribe = async (planId: number) => {
setSubscribingId(planId);
setSubError(null);
try {
// Try authenticated subscribe first — import api dynamically to avoid
// pulling auth interceptor into public context if user isn't logged in
const { api } = await import('@/lib/api');
const { data } = await api.post('/payments/subscribe', {
planId,
frequency: yearly ? 'yearly' : 'monthly',
});
if (data.url) {
window.location.href = data.url;
}
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status;
if (status === 401) {
setSubError('Please sign in to subscribe');
} else {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to start checkout';
setSubError(msg);
}
} finally {
setSubscribingId(null);
}
};
const styles = {
container: { maxWidth: 900, margin: '0 auto', padding: '0 16px', color: '#fff' } as React.CSSProperties,
toggleRow: { display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, marginBottom: 32 } as React.CSSProperties,
toggle: (active: boolean) => ({
padding: '6px 16px',
background: active ? '#722ed1' : 'rgba(255,255,255,0.1)',
color: '#fff',
border: 'none',
borderRadius: 20,
cursor: 'pointer',
fontWeight: active ? 600 : 400,
fontSize: '0.9rem',
} as React.CSSProperties),
saveBadge: { background: '#52c41a', color: '#fff', padding: '2px 8px', borderRadius: 10, fontSize: '0.75rem', fontWeight: 600 } as React.CSSProperties,
grid: { display: 'flex', gap: 24, justifyContent: 'center', flexWrap: 'wrap' as const } as React.CSSProperties,
card: (highlighted: boolean) => ({
flex: '1 1 250px',
maxWidth: 300,
padding: 24,
background: highlighted ? 'rgba(114,46,209,0.15)' : 'rgba(255,255,255,0.06)',
border: highlighted ? '2px solid #722ed1' : '1px solid rgba(255,255,255,0.12)',
borderRadius: 12,
textAlign: 'center' as const,
position: 'relative' as const,
}),
planName: { fontSize: '1.25rem', fontWeight: 600, marginBottom: 8 } as React.CSSProperties,
price: { fontSize: '2rem', fontWeight: 700, margin: '8px 0' } as React.CSSProperties,
period: { fontSize: '0.9rem', opacity: 0.7 } as React.CSSProperties,
featureList: { textAlign: 'left' as const, padding: '12px 0', listStyle: 'none', margin: 0 } as React.CSSProperties,
feature: { padding: '4px 0', fontSize: '0.9rem' } as React.CSSProperties,
checkMark: { color: '#52c41a', marginRight: 8 } as React.CSSProperties,
subscribeBtn: (subscribing: boolean) => ({
width: '100%',
padding: '12px 20px',
background: '#722ed1',
color: '#fff',
border: 'none',
borderRadius: 8,
fontWeight: 600,
cursor: 'pointer',
marginTop: 16,
opacity: subscribing ? 0.7 : 1,
} as React.CSSProperties),
error: { color: '#ff4d4f', fontSize: '0.85rem', marginTop: 8, textAlign: 'center' as const } as React.CSSProperties,
popularTag: {
position: 'absolute' as const,
top: -12,
right: 16,
background: '#722ed1',
color: '#fff',
padding: '4px 12px',
borderRadius: 12,
fontSize: '0.75rem',
fontWeight: 600,
} as React.CSSProperties,
};
if (loading) {
return <div style={{ ...styles.container, textAlign: 'center' }}>Loading plans...</div>;
}
if (error) {
return (
<div style={{ ...styles.container, textAlign: 'center' }}>
<p style={{ opacity: 0.7 }}>{error}</p>
<a href="/pricing" style={{ color: '#722ed1' }}>View pricing page</a>
</div>
);
}
const hasYearlyPlans = plans.some(p => p.yearlyPriceCAD !== null);
return (
<div style={styles.container}>
{showYearlyToggle && hasYearlyPlans && (
<div style={styles.toggleRow}>
<button type="button" style={styles.toggle(!yearly)} onClick={() => setYearly(false)}>Monthly</button>
<button type="button" style={styles.toggle(yearly)} onClick={() => setYearly(true)}>
Yearly <span style={styles.saveBadge}>Save 20%</span>
</button>
</div>
)}
<div style={styles.grid}>
{/* Free tier */}
<div style={styles.card(false)}>
<div style={styles.planName}>Free</div>
<div style={styles.price}>$0</div>
<div style={styles.period}>forever</div>
<ul style={styles.featureList}>
<li style={styles.feature}><span style={styles.checkMark}>&#x2713;</span>Access public content</li>
<li style={styles.feature}><span style={styles.checkMark}>&#x2713;</span>Browse campaigns</li>
<li style={styles.feature}><span style={styles.checkMark}>&#x2713;</span>View public map</li>
</ul>
</div>
{plans.map((plan) => {
const price = yearly && plan.yearlyPriceCAD ? plan.yearlyPriceCAD : plan.priceCAD;
const period = yearly && plan.yearlyPriceCAD ? '/year' : '/month';
const features = (plan.features || []) as string[];
const isPopular = plan.tier >= 2;
return (
<div key={plan.id} style={styles.card(isPopular)}>
{isPopular && <div style={styles.popularTag}>Popular</div>}
<div style={styles.planName}>{plan.name}</div>
<div style={styles.price}>${(price / 100).toFixed(2)}</div>
<div style={styles.period}>{period}</div>
{plan.description && (
<p style={{ fontSize: '0.85rem', opacity: 0.7, margin: '8px 0' }}>{plan.description}</p>
)}
<ul style={styles.featureList}>
{features.map((f, i) => (
<li key={i} style={styles.feature}><span style={styles.checkMark}>&#x2713;</span>{f}</li>
))}
</ul>
<button
type="button"
style={styles.subscribeBtn(subscribingId === plan.id)}
onClick={() => handleSubscribe(plan.id)}
disabled={subscribingId === plan.id}
>
{subscribingId === plan.id ? 'Processing...' : 'Subscribe'}
</button>
</div>
);
})}
</div>
{subError && <div style={styles.error}>{subError}</div>}
</div>
);
}

View File

@ -0,0 +1,151 @@
import { useState, useEffect } from 'react';
import { Modal, Card, Row, Col, Typography, Tag, Spin, Empty, Input } from 'antd';
import { ShoppingCartOutlined, SearchOutlined, CheckCircleOutlined } from '@ant-design/icons';
import axios from 'axios';
import type { Product, ProductType } from '@/types/api';
const { Text, Paragraph } = Typography;
export interface ProductInsertResult {
product: Product;
}
interface ProductInsertModalProps {
open: boolean;
onClose: () => void;
onInsert: (result: ProductInsertResult) => void;
}
const typeColors: Record<ProductType, string> = {
DIGITAL: '#1890ff',
EVENT: '#52c41a',
DONATION: '#722ed1',
};
export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertModalProps) {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
useEffect(() => {
if (open && products.length === 0) {
setLoading(true);
setError(null);
axios.get('/api/payments/products')
.then(({ data }) => setProducts(data))
.catch(() => setError('Failed to load products'))
.finally(() => setLoading(false));
}
if (open) {
setSelectedId(null);
setSearch('');
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const handleOk = () => {
const product = products.find(p => p.id === selectedId);
if (!product) return;
onInsert({ product });
onClose();
};
const filtered = products.filter(p => {
if (!search) return true;
const q = search.toLowerCase();
return p.title.toLowerCase().includes(q) || p.slug.toLowerCase().includes(q) || p.type.toLowerCase().includes(q);
});
return (
<Modal
title="Insert Product Card"
open={open}
onCancel={onClose}
onOk={handleOk}
okText="Insert"
okButtonProps={{ disabled: !selectedId }}
width={640}
>
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
Select a product to embed as an inline purchase card.
</Paragraph>
<Input
prefix={<SearchOutlined />}
placeholder="Search products..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ marginBottom: 16 }}
allowClear
/>
{loading && <Spin style={{ display: 'block', margin: '40px auto' }} />}
{error && <Text type="danger">{error}</Text>}
{!loading && !error && filtered.length === 0 && (
<Empty
description={products.length === 0 ? 'No products found. Create products in the Payments section first.' : 'No products match your search.'}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
<Row gutter={[12, 12]}>
{filtered.map((product) => {
const isSelected = selectedId === product.id;
const isSoldOut = product.maxPurchases !== null && product.purchaseCount >= (product.maxPurchases ?? 0);
return (
<Col xs={24} sm={12} key={product.id}>
<Card
size="small"
hoverable
onClick={() => setSelectedId(product.id)}
style={{
border: isSelected ? '2px solid #722ed1' : '1px solid #303030',
background: isSelected ? 'rgba(114,46,209,0.08)' : undefined,
cursor: 'pointer',
position: 'relative',
}}
>
{isSelected && (
<CheckCircleOutlined style={{ position: 'absolute', top: 8, right: 8, color: '#722ed1', fontSize: 18 }} />
)}
<div style={{ display: 'flex', gap: 12 }}>
<div style={{
width: 56,
height: 56,
borderRadius: 8,
background: product.imageUrl
? `url(${product.imageUrl}) center/cover no-repeat`
: 'linear-gradient(135deg, #9d4edd, #722ed1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
{!product.imageUrl && <ShoppingCartOutlined style={{ color: '#fff', fontSize: 22 }} />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 2 }}>
<Tag color={typeColors[product.type]} style={{ margin: 0, fontSize: 10 }}>{product.type}</Tag>
{isSoldOut && <Tag color="red" style={{ margin: 0, fontSize: 10 }}>Sold Out</Tag>}
</div>
<Text strong ellipsis style={{ display: 'block' }}>{product.title}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>${(product.priceCAD / 100).toFixed(2)}</Text>
{product.description && (
<Paragraph type="secondary" ellipsis={{ rows: 1 }} style={{ fontSize: 11, margin: '2px 0 0' }}>
{product.description}
</Paragraph>
)}
</div>
</div>
</Card>
</Col>
);
})}
</Row>
</div>
</Modal>
);
}

View File

@ -0,0 +1,194 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import type { Product, ProductType } from '@/types/api';
interface ProductWidgetProps {
productSlug: string;
buttonText?: string;
}
export function ProductWidget({ productSlug, buttonText = 'Buy Now' }: ProductWidgetProps) {
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [purchasing, setPurchasing] = useState(false);
const [purchaseError, setPurchaseError] = useState<string | null>(null);
useEffect(() => {
if (!productSlug) {
setError('No product specified');
setLoading(false);
return;
}
axios.get('/api/payments/products')
.then(({ data }) => {
const found = (data as Product[]).find(p => p.slug === productSlug);
if (found) {
setProduct(found);
} else {
setError('Product not found');
}
})
.catch(() => setError('Failed to load product'))
.finally(() => setLoading(false));
}, [productSlug]);
const handlePurchase = async () => {
if (!product) return;
const email = prompt('Enter your email to purchase:');
if (!email) return;
setPurchasing(true);
setPurchaseError(null);
try {
const { data } = await axios.post('/api/payments/purchase', {
productId: product.id,
buyerEmail: email,
});
if (data.url) {
window.location.href = data.url;
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to start checkout';
setPurchaseError(msg);
} finally {
setPurchasing(false);
}
};
const typeColors: Record<ProductType, string> = {
DIGITAL: '#1890ff',
EVENT: '#52c41a',
DONATION: '#722ed1',
};
const styles = {
card: {
maxWidth: 380,
margin: '0 auto',
borderRadius: 12,
overflow: 'hidden',
background: '#1b2838',
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
color: '#fff',
} as React.CSSProperties,
imageArea: {
paddingBottom: '56.25%',
position: 'relative' as const,
overflow: 'hidden',
} as React.CSSProperties,
image: {
position: 'absolute' as const,
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover' as const,
} as React.CSSProperties,
imagePlaceholder: {
position: 'absolute' as const,
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, #9d4edd 0%, #722ed1 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 48,
} as React.CSSProperties,
body: { padding: 16 } as React.CSSProperties,
tag: (type: ProductType) => ({
display: 'inline-block',
padding: '2px 10px',
background: typeColors[type] || '#666',
color: '#fff',
borderRadius: 10,
fontSize: '0.75rem',
fontWeight: 600,
marginBottom: 8,
} as React.CSSProperties),
title: { fontSize: '1.1rem', fontWeight: 600, margin: '0 0 6px' } as React.CSSProperties,
desc: { fontSize: '0.85rem', opacity: 0.7, margin: '0 0 12px', lineHeight: 1.4 } as React.CSSProperties,
priceRow: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 12,
} as React.CSSProperties,
price: { fontSize: '1.25rem', fontWeight: 700 } as React.CSSProperties,
buyBtn: {
padding: '8px 20px',
background: '#9d4edd',
color: '#fff',
border: 'none',
borderRadius: 6,
fontWeight: 600,
cursor: 'pointer',
opacity: purchasing ? 0.7 : 1,
} as React.CSSProperties,
soldOut: {
padding: '4px 12px',
background: '#ff4d4f',
color: '#fff',
borderRadius: 10,
fontSize: '0.8rem',
fontWeight: 600,
} as React.CSSProperties,
error: { color: '#ff4d4f', fontSize: '0.85rem', marginTop: 8 } as React.CSSProperties,
};
if (loading) {
return (
<div style={{ ...styles.card, padding: 40, textAlign: 'center' }}>Loading...</div>
);
}
if (error || !product) {
return (
<div style={{ ...styles.card, padding: 24, textAlign: 'center' }}>
<p style={{ opacity: 0.7, margin: 0 }}>{error || 'Product not found'}</p>
<a href="/shop" style={{ color: '#9d4edd', fontSize: '0.9rem' }}>Browse shop</a>
</div>
);
}
const isSoldOut = product.maxPurchases !== null && product.purchaseCount >= (product.maxPurchases ?? 0);
return (
<div style={styles.card}>
<div style={styles.imageArea}>
{product.imageUrl ? (
<img src={product.imageUrl} alt={product.title} style={styles.image} />
) : (
<div style={styles.imagePlaceholder}>&#x1F6D2;</div>
)}
</div>
<div style={styles.body}>
<span style={styles.tag(product.type)}>{product.type}</span>
<h3 style={styles.title}>{product.title}</h3>
{product.description && (
<p style={styles.desc}>{product.description.length > 120 ? product.description.slice(0, 120) + '...' : product.description}</p>
)}
<div style={styles.priceRow}>
<span style={styles.price}>${(product.priceCAD / 100).toFixed(2)}</span>
{isSoldOut ? (
<span style={styles.soldOut}>Sold Out</span>
) : (
<button
type="button"
style={styles.buyBtn}
onClick={handlePurchase}
disabled={purchasing}
>
{purchasing ? 'Processing...' : buttonText}
</button>
)}
</div>
{purchaseError && <div style={styles.error}>{purchaseError}</div>}
</div>
</div>
);
}

View File

@ -14,6 +14,7 @@ import {
Row,
Col,
Divider,
Tooltip,
} from 'antd';
import {
PlusOutlined,
@ -24,10 +25,11 @@ import {
MailOutlined,
LinkOutlined,
EyeOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { useOutletContext } from 'react-router-dom';
import { useOutletContext, useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type {
@ -253,48 +255,54 @@ export default function CampaignsPage() {
render: (_: unknown, record: Campaign) => (
<Space>
{record.status === 'ACTIVE' && (
<Tooltip title="View public page">
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => window.open(`/campaign/${record.slug}`, '_blank')}
/>
</Tooltip>
)}
<Tooltip title="Copy public link">
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => window.open(`/campaign/${record.slug}`, '_blank')}
title="View public page"
icon={<LinkOutlined />}
onClick={() => {
const url = `${window.location.origin}/campaign/${record.slug}`;
navigator.clipboard.writeText(url);
message.success('Campaign link copied');
}}
/>
)}
<Button
type="link"
size="small"
icon={<LinkOutlined />}
onClick={() => {
const url = `${window.location.origin}/campaign/${record.slug}`;
navigator.clipboard.writeText(url);
message.success('Campaign link copied');
}}
title="Copy public link"
/>
<Button
type="link"
size="small"
icon={<MailOutlined />}
onClick={() => {
setEmailsCampaign(record);
setEmailsDrawerOpen(true);
}}
title="View emails"
/>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
title="Edit campaign"
/>
</Tooltip>
<Tooltip title="View emails">
<Button
type="link"
size="small"
icon={<MailOutlined />}
onClick={() => {
setEmailsCampaign(record);
setEmailsDrawerOpen(true);
}}
/>
</Tooltip>
<Tooltip title="Edit campaign">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
/>
</Tooltip>
<Popconfirm
title="Delete this campaign?"
description="All associated emails and responses will also be deleted."
onConfirm={() => handleDelete(record.id)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />} title="Delete" />
<Tooltip title="Delete">
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
</Space>
),
@ -308,7 +316,7 @@ export default function CampaignsPage() {
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input />
<Input placeholder="e.g. Save Our Local Library" />
</Form.Item>
<Form.Item name="description" label="Description">
<TextArea rows={2} />
@ -318,7 +326,7 @@ export default function CampaignsPage() {
label="Email Subject"
rules={[{ required: true, message: 'Email subject is required' }]}
>
<Input />
<Input placeholder="e.g. Demand Action on Climate" />
</Form.Item>
<Form.Item
name="emailBody"
@ -347,16 +355,16 @@ export default function CampaignsPage() {
</Form.Item>
<Divider orientation="left" plain>
Feature Flags
Campaign Options
</Divider>
<Row gutter={[16, 8]}>
<Col xs={24} sm={12}>
<Form.Item name="allowSmtpEmail" label="Allow SMTP Email" valuePropName="checked" initialValue={true}>
<Form.Item name="allowSmtpEmail" label={<span>Send Now (via server) <Tooltip title="Emails are sent directly through the platform's mail server on behalf of the user"><QuestionCircleOutlined style={{ color: 'rgba(255,255,255,0.35)' }} /></Tooltip></span>} valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="allowMailtoLink" label="Allow Mailto Link" valuePropName="checked" initialValue={true}>
<Form.Item name="allowMailtoLink" label={<span>Open in Email App <Tooltip title="Opens the user's default email app (Gmail, Outlook, etc.) with the message pre-filled"><QuestionCircleOutlined style={{ color: 'rgba(255,255,255,0.35)' }} /></Tooltip></span>} valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Col>
@ -400,16 +408,25 @@ export default function CampaignsPage() {
);
const { setPageHeader } = useOutletContext<AppOutletContext>();
const navigate = useNavigate();
const headerActions = useMemo(() => (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
Create Campaign
</Button>
), []);
<Space>
<Button
icon={<MailOutlined />}
onClick={() => navigate('/app/email-queue')}
>
Email Queue
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
Create Campaign
</Button>
</Space>
), [navigate]);
useEffect(() => {
setPageHeader({ title: 'Campaigns', actions: headerActions });
@ -453,6 +470,7 @@ export default function CampaignsPage() {
showTotal: (total) => `${total} campaigns`,
}}
onChange={handleTableChange}
locale={{ emptyText: 'No campaigns yet. Create your first campaign to get started.' }}
/>
{/* Create Modal */}

View File

@ -276,6 +276,7 @@ export default function CanvassDashboardPage() {
size="small"
pagination={false}
scroll={{ x: true }}
locale={{ emptyText: 'No activity recorded yet.' }}
/>
{activityPagination && activityPagination.total > 10 && (
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
@ -292,6 +293,7 @@ export default function CanvassDashboardPage() {
size="small"
pagination={false}
scroll={{ x: true }}
locale={{ emptyText: 'No cuts assigned.' }}
/>
</Card>
@ -303,6 +305,7 @@ export default function CanvassDashboardPage() {
size="small"
pagination={false}
scroll={{ x: true }}
locale={{ emptyText: 'No sessions recorded yet.' }}
/>
</Card>
</div>

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useOutletContext } from 'react-router-dom';
import {
Row, Col, Card, Statistic, Typography, Tag, Badge, Button,
Progress, Space, Tooltip, Spin, Grid, Flex, Segmented,
@ -56,6 +56,7 @@ import type {
DashboardSummary, QueueStats, ServicesStatus, ServicesConfig,
SystemInfo, ContainerInfo, WeatherData, ApiMetrics,
TimeSeriesResult, ContainerResource, ContainerResourcesResponse,
AppOutletContext,
} from '@/types/api';
const { Text, Title } = Typography;
@ -149,11 +150,17 @@ const TIME_SERIES_METRICS = 'request_rate_2xx,request_rate_4xx,request_rate_5xx,
export default function DashboardPage() {
const navigate = useNavigate();
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { user } = useAuthStore();
const { settings } = useSettingsStore();
const screens = Grid.useBreakpoint();
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
useEffect(() => {
setPageHeader({ title: 'Dashboard' });
return () => setPageHeader(null);
}, [setPageHeader]);
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [queue, setQueue] = useState<QueueStats | null>(null);
const [health, setHealth] = useState<HealthStatus | null>(null);
@ -168,6 +175,9 @@ export default function DashboardPage() {
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [activeView, setActiveView] = useState<'dashboard' | 'homepage'>('dashboard');
const [homepageUrl, setHomepageUrl] = useState<string | null>(null);
const [onboardingDismissed, setOnboardingDismissed] = useState(() =>
localStorage.getItem('cml-onboarding-dismissed') === 'true'
);
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
const fetchData = useCallback(async () => {
@ -218,7 +228,7 @@ export default function DashboardPage() {
const showInfluence = settings?.enableInfluence !== false;
const showMap = settings?.enableMap !== false;
const showMedia = settings?.enableMediaFeatures === true;
const showMedia = settings?.enableMediaFeatures !== false;
const geocodePct = summary && summary.locations.total > 0
? Math.round((summary.locations.geocoded / summary.locations.total) * 100) : 0;
@ -346,6 +356,50 @@ export default function DashboardPage() {
{/* === Dashboard view === */}
{activeView === 'dashboard' && <>
{/* === Onboarding Checklist === */}
{!onboardingDismissed && summary && (() => {
const items = [
{ label: 'Configure organization settings', path: '/app/settings', done: !!(settings?.organizationName && settings.organizationName !== 'Changemaker Lite'), show: true },
{ label: 'Create your first campaign', path: '/app/campaigns', done: (summary.campaigns.total ?? 0) > 0, show: showInfluence },
{ label: 'Import locations', path: '/app/map', done: (summary.locations.total ?? 0) > 0, show: showMap },
{ label: 'Create a volunteer shift', path: '/app/map/shifts', done: (summary.shifts.upcoming ?? 0) > 0, show: showMap },
{ label: 'Upload a video', path: '/app/media/library', done: (summary.videos?.total ?? 0) > 0, show: showMedia },
].filter(i => i.show);
const doneCount = items.filter(i => i.done).length;
if (doneCount >= items.length) return null;
return (
<Card
size="small"
style={{ marginBottom: 16 }}
title={<><RocketOutlined style={{ marginRight: 6 }} />Getting Started</>}
extra={
<Space>
<Tag color="blue">{doneCount}/{items.length} complete</Tag>
<Button size="small" type="text" onClick={() => { setOnboardingDismissed(true); localStorage.setItem('cml-onboarding-dismissed', 'true'); }}>Dismiss</Button>
</Space>
}
>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
{items.map(item => (
<div
key={item.path}
onClick={() => !item.done && navigate(item.path)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 8px', borderRadius: 6,
cursor: item.done ? 'default' : 'pointer',
opacity: item.done ? 0.6 : 1,
}}
>
<CheckCircleFilled style={{ color: item.done ? '#52c41a' : 'rgba(255,255,255,0.15)', fontSize: 16 }} />
<Text style={{ textDecoration: item.done ? 'line-through' : 'none' }}>{item.label}</Text>
</div>
))}
</Space>
</Card>
);
})()}
{/* === Weather + Key Metrics Row === */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
{weather && (

View File

@ -0,0 +1,261 @@
import { useState, useEffect, useCallback } from 'react';
import { Row, Col, Card, Statistic, Table, Select, Button, App, Spin, Empty } from 'antd';
import {
EyeOutlined,
UserOutlined,
CalendarOutlined,
ReloadOutlined,
LinkOutlined,
} from '@ant-design/icons';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from 'recharts';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/types/api';
interface TopPage {
path: string;
views: number;
uniqueSessions: number;
}
interface DayViews {
date: string;
views: number;
uniqueSessions: number;
}
interface TopReferrer {
referrer: string;
count: number;
}
interface AnalyticsSummary {
totalViews: number;
uniqueSessions: number;
topPages: TopPage[];
viewsByDay: DayViews[];
topReferrers: TopReferrer[];
}
const topPagesColumns = [
{
title: 'Page',
dataIndex: 'path',
key: 'path',
ellipsis: true,
render: (path: string) => (
<span title={path} style={{ fontFamily: 'monospace', fontSize: 12 }}>
{path}
</span>
),
},
{
title: 'Views',
dataIndex: 'views',
key: 'views',
width: 90,
sorter: (a: TopPage, b: TopPage) => a.views - b.views,
defaultSortOrder: 'descend' as const,
},
{
title: 'Unique',
dataIndex: 'uniqueSessions',
key: 'uniqueSessions',
width: 90,
sorter: (a: TopPage, b: TopPage) => a.uniqueSessions - b.uniqueSessions,
},
];
const referrerColumns = [
{
title: 'Referrer',
dataIndex: 'referrer',
key: 'referrer',
ellipsis: true,
render: (url: string) => {
try {
const hostname = new URL(url).hostname;
return (
<a href={url} target="_blank" rel="noopener noreferrer" title={url}>
{hostname}
</a>
);
} catch {
return <span title={url}>{url}</span>;
}
},
},
{
title: 'Count',
dataIndex: 'count',
key: 'count',
width: 80,
},
];
export default function DocsAnalyticsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const [days, setDays] = useState(30);
const [data, setData] = useState<AnalyticsSummary | null>(null);
const [loading, setLoading] = useState(true);
const { message } = App.useApp();
useEffect(() => {
setPageHeader({ title: 'Documentation Analytics' });
return () => setPageHeader(null);
}, [setPageHeader]);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get('/docs-analytics/summary', { params: { days } });
setData(res.data);
} catch {
message.error('Failed to load analytics data');
} finally {
setLoading(false);
}
}, [days, message]);
useEffect(() => {
fetchData();
}, [fetchData]);
const avgViewsPerDay = data && days > 0
? Math.round((data.totalViews / days) * 10) / 10
: 0;
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{ flex: 1 }} />
<Select
value={days}
onChange={setDays}
style={{ width: 130 }}
options={[
{ label: 'Last 7 days', value: 7 },
{ label: 'Last 30 days', value: 30 },
{ label: 'Last 90 days', value: 90 },
]}
/>
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
Refresh
</Button>
</div>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
) : !data ? (
<Empty description="No analytics data available" />
) : (
<>
{/* Summary Cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={8}>
<Card>
<Statistic
title="Total Page Views"
value={data.totalViews}
prefix={<EyeOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic
title="Unique Sessions"
value={data.uniqueSessions}
prefix={<UserOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic
title="Avg Views / Day"
value={avgViewsPerDay}
prefix={<CalendarOutlined />}
/>
</Card>
</Col>
</Row>
{/* Views Over Time Chart */}
<Card title="Views Over Time" style={{ marginBottom: 24 }}>
{data.viewsByDay.length === 0 ? (
<Empty description="No data for this period" />
) : (
<div style={{ width: '100%', height: 300 }}>
<ResponsiveContainer>
<AreaChart data={data.viewsByDay} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} />
<XAxis
dataKey="date"
tick={{ fontSize: 11 }}
interval="preserveStartEnd"
/>
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 6 }} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="views"
name="Page Views"
stroke="#1890ff"
fill="#1890ff"
fillOpacity={0.3}
/>
<Area
type="monotone"
dataKey="uniqueSessions"
name="Unique Sessions"
stroke="#52c41a"
fill="#52c41a"
fillOpacity={0.3}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</Card>
{/* Top Pages + Top Referrers */}
<Row gutter={[16, 16]}>
<Col xs={24} lg={14}>
<Card title="Top Pages">
<Table
dataSource={data.topPages}
columns={topPagesColumns}
rowKey="path"
pagination={false}
size="small"
scroll={{ x: 300 }}
/>
</Card>
</Col>
<Col xs={24} lg={10}>
<Card title={<><LinkOutlined /> Top Referrers</>}>
{data.topReferrers.length === 0 ? (
<Empty description="No referrer data" />
) : (
<Table
dataSource={data.topReferrers}
columns={referrerColumns}
rowKey="referrer"
pagination={false}
size="small"
/>
)}
</Card>
</Col>
</Row>
</>
)}
</div>
);
}

View File

@ -52,6 +52,9 @@ import {
UploadOutlined,
InboxOutlined,
PlayCircleOutlined,
HeartOutlined,
CrownOutlined,
ShoppingCartOutlined,
} from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import type { OnMount } from '@monaco-editor/react';
@ -64,6 +67,10 @@ import type { AppOutletContext } from '@/components/AppLayout';
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
import type { Video as PickerVideo } from '@/components/media/VideoPickerModal';
import { generateVideoCardHtml } from '@/utils/videoCardHtml';
import { DonateInsertModal } from '@/components/payments/DonateInsertModal';
import type { DonateInsertResult } from '@/components/payments/DonateInsertModal';
import { ProductInsertModal } from '@/components/payments/ProductInsertModal';
import type { ProductInsertResult } from '@/components/payments/ProductInsertModal';
type LayoutMode = 'split' | 'editor' | 'preview';
@ -313,9 +320,143 @@ const SNIPPETS: MkDocsSnippet[] = [
{ id: 'footnote', label: 'Footnote', group: 'insert', type: 'insert', template: '[^1]\n\n[^1]: Text' },
{ id: 'def-list', label: 'Definition List', group: 'insert', type: 'insert', template: 'Term\n: Definition' },
{ id: 'video-card', label: 'Video Card', group: 'insert', type: 'insert', template: '' },
{ id: 'donate-button', label: 'Donate Button', group: 'insert', type: 'insert', template: '' },
{ id: 'pricing-table', label: 'Pricing Table', group: 'insert', type: 'insert', template: '' },
{ id: 'product-card', label: 'Product Card', group: 'insert', type: 'insert', template: '' },
{ id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' },
];
// --- Inline Donate Block Generator ---
// Produces HTML with data-role attributes for hydration by payment-widgets.js.
// No inline <script> tags — all interactivity provided by the MkDocs payment-widgets.js script.
interface InlineDonateOpts {
uid: string; // unique suffix to prevent DOM ID conflicts
heading: string;
description: string;
amounts: number[]; // cents
preSelectedCents: number | null;
showAmountPicker: boolean;
}
function generateInlineDonateHtml(o: InlineDonateOpts): string {
const id = `cm-donate-${o.uid}`;
const inputStyle = 'width:100%;padding:10px 14px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.25);border-radius:8px;color:#fff;font-size:0.95rem;box-sizing:border-box;margin-bottom:10px;outline:none;';
const submitBtnStyle = 'width:100%;padding:14px 24px;background:#eb2f96;color:#fff;border:none;border-radius:8px;font-weight:600;font-size:1.05rem;cursor:pointer;';
// Amount buttons HTML (for full card)
let amountButtonsHtml = '';
if (o.showAmountPicker) {
const btns = o.amounts.map((cents) => {
const dollars = (cents / 100).toFixed(0);
return `<button type="button" class="cm-amt-btn" data-cents="${cents}">$${dollars}</button>`;
}).join('\n ');
amountButtonsHtml = `
<div data-role="amounts" style="margin-bottom: 16px;">
${btns}
<button type="button" class="cm-amt-btn" data-cents="custom">Custom</button>
</div>
<div data-role="custom-wrap" style="display:none;margin-bottom:12px;">
<input type="number" data-role="custom-input" min="1" step="1" placeholder="Enter amount ($)" style="${inputStyle}" />
</div>`;
}
const formDisplay = o.showAmountPicker ? 'none' : 'block';
const amountsAttr = o.amounts.join(',');
const lines = [
`<div id="${id}" data-heading="${o.heading}" data-amounts="${amountsAttr}" data-preselected="${o.preSelectedCents || ''}" data-show-picker="${o.showAmountPicker}" style="text-align:center;padding:40px 20px;background:linear-gradient(135deg,#2d1b69,#1a1a2e);border-radius:12px;margin:16px 0;max-width:560px;margin-left:auto;margin-right:auto;">`,
` <p style="font-size:48px;margin:0;">&#x2764;&#xFE0F;</p>`,
` <h2 style="color:#fff;margin:12px 0;">${o.heading}</h2>`,
` <p style="color:rgba(255,255,255,0.8);margin-bottom:24px;">${o.description}</p>`,
amountButtonsHtml,
` <div data-role="form" style="display:${formDisplay};max-width:360px;margin:0 auto;text-align:left;">`,
` <input type="email" data-role="email" placeholder="your@email.com *" style="${inputStyle}" required />`,
` <input type="text" data-role="name" placeholder="Name (optional)" style="${inputStyle}" />`,
` <div style="margin-bottom:12px;color:rgba(255,255,255,0.7);font-size:0.85rem;">`,
` <label><input type="checkbox" data-role="anonymous" style="margin-right:6px;" />Make my donation anonymous</label>`,
` </div>`,
` <div data-role="error" style="color:#ff4d4f;font-size:0.9rem;margin-bottom:8px;display:none;"></div>`,
` <button type="button" data-role="submit" style="${submitBtnStyle}">`,
o.preSelectedCents ? `Donate $${(o.preSelectedCents / 100).toFixed(0)}` : 'Donate',
` </button>`,
` <p style="margin-top:12px;font-size:0.75rem;color:rgba(255,255,255,0.4);text-align:center;">`,
` Secure payment via Stripe.`,
` </p>`,
` </div>`,
` <div class="cm-payment-fallback"><a href="/donate${o.preSelectedCents ? `?amount=${o.preSelectedCents}` : ''}" style="background:#eb2f96;">Donate Now</a></div>`,
` <noscript><a href="/donate${o.preSelectedCents ? `?amount=${o.preSelectedCents}` : ''}" style="display:inline-block;padding:14px 36px;background:#eb2f96;color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">Donate Now</a></noscript>`,
`</div>`,
];
return lines.join('\n');
}
// --- Inline Product Block Generator ---
// Produces HTML with data-role attributes for hydration by payment-widgets.js.
interface InlineProductOpts {
uid: string;
productId: string;
title: string;
description: string;
priceCents: number;
type: string;
imageUrl: string | null;
isSoldOut: boolean;
}
function generateInlineProductHtml(o: InlineProductOpts): string {
const id = `cm-product-${o.uid}`;
const priceStr = `$${(o.priceCents / 100).toFixed(2)}`;
const typeColor = o.type === 'EVENT' ? '#52c41a' : o.type === 'DONATION' ? '#722ed1' : '#1890ff';
const inputStyle = 'width:100%;padding:10px 14px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.25);border-radius:8px;color:#fff;font-size:0.95rem;box-sizing:border-box;margin-bottom:10px;outline:none;';
const buyBtnStyle = o.isSoldOut
? 'width:100%;padding:14px 24px;background:#555;color:#999;border:none;border-radius:8px;font-weight:600;font-size:1.05rem;cursor:not-allowed;'
: 'width:100%;padding:14px 24px;background:#722ed1;color:#fff;border:none;border-radius:8px;font-weight:600;font-size:1.05rem;cursor:pointer;';
const imageHtml = o.imageUrl
? `<div style="width:100%;height:180px;border-radius:8px;overflow:hidden;margin-bottom:16px;"><img src="${o.imageUrl}" alt="${o.title}" style="width:100%;height:100%;object-fit:cover;" /></div>`
: `<div style="width:80px;height:80px;border-radius:12px;background:linear-gradient(135deg,#9d4edd,#722ed1);display:flex;align-items:center;justify-content:center;margin:0 auto 16px;"><span style="font-size:36px;color:#fff;">&#x1F6D2;</span></div>`;
const lines = [
`<div id="${id}" data-product-id="${o.productId}" data-price-label="${priceStr}" style="text-align:center;padding:32px 20px;background:linear-gradient(135deg,#1a1a2e,#16213e);border-radius:12px;margin:16px 0;max-width:420px;margin-left:auto;margin-right:auto;">`,
` ${imageHtml}`,
` <div style="display:inline-block;padding:2px 10px;border-radius:4px;background:${typeColor};color:#fff;font-size:11px;font-weight:600;margin-bottom:8px;">${o.type}</div>`,
o.isSoldOut ? ` <div style="display:inline-block;padding:2px 10px;border-radius:4px;background:#ff4d4f;color:#fff;font-size:11px;font-weight:600;margin-bottom:8px;margin-left:6px;">SOLD OUT</div>` : '',
` <h3 style="color:#fff;margin:8px 0 4px;">${o.title}</h3>`,
o.description ? ` <p style="color:rgba(255,255,255,0.7);font-size:0.9rem;margin-bottom:12px;">${o.description}</p>` : '',
` <p style="color:#fff;font-size:1.4rem;font-weight:700;margin-bottom:20px;">${priceStr}</p>`,
];
if (o.isSoldOut) {
lines.push(
` <button disabled style="${buyBtnStyle}">Sold Out</button>`,
`</div>`,
);
return lines.filter(Boolean).join('\n');
}
lines.push(
` <div data-role="form" style="max-width:320px;margin:0 auto;text-align:left;">`,
` <input type="email" data-role="email" placeholder="your@email.com *" style="${inputStyle}" required />`,
` <input type="text" data-role="name" placeholder="Name (optional)" style="${inputStyle}" />`,
` <div data-role="error" style="color:#ff4d4f;font-size:0.9rem;margin-bottom:8px;display:none;"></div>`,
` <button type="button" data-role="submit" style="${buyBtnStyle}">Buy Now &mdash; ${priceStr}</button>`,
` <p style="margin-top:12px;font-size:0.75rem;color:rgba(255,255,255,0.4);text-align:center;">`,
` Secure payment via Stripe.`,
` </p>`,
` </div>`,
` <div class="cm-payment-fallback"><a href="/shop" style="background:#722ed1;">View in Shop</a></div>`,
` <noscript><a href="/shop" style="display:inline-block;padding:14px 36px;background:#722ed1;color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">View in Shop</a></noscript>`,
`</div>`,
);
return lines.filter(Boolean).join('\n');
}
function applySnippet(
ed: monacoEditor.IStandaloneCodeEditor,
snippet: MkDocsSnippet,
@ -405,6 +546,8 @@ export default function DocsPage() {
const [contextPath, setContextPath] = useState<string>('');
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
const [donateInsertOpen, setDonateInsertOpen] = useState(false);
const [productInsertOpen, setProductInsertOpen] = useState(false);
const [dragOver, setDragOver] = useState(false);
const dragCounter = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -583,10 +726,39 @@ export default function DocsPage() {
setVideoPickerOpen(true);
return;
}
// Donate button — opens variant picker modal
if (snippetId === 'donate-button') {
setDonateInsertOpen(true);
return;
}
// Product card — opens product picker modal
if (snippetId === 'product-card') {
setProductInsertOpen(true);
return;
}
// Pricing table — static CTA (plans are dynamic, so link out)
if (snippetId === 'pricing-table') {
const appUrl = config
? `${window.location.protocol}//${config.domain.replace(/^([^.]+)/, 'app')}`
: window.location.origin;
const html = `<div style="text-align: center; padding: 40px 20px; background: linear-gradient(135deg, #1a1a2e, #16213e); border-radius: 12px; margin: 16px 0;">\n <h2 style="color: #fff; margin: 12px 0;">Choose Your Plan</h2>\n <p style="color: rgba(255,255,255,0.8); margin-bottom: 24px;">Get access to exclusive content and features.</p>\n <a href="${appUrl}/pricing" style="display: inline-block; padding: 14px 36px; background: #722ed1; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 1.1rem;">View Plans</a>\n</div>`;
const ed = monacoEditorRef.current;
if (ed) {
const sel = ed.getSelection();
if (sel) {
ed.executeEdits('payment-block-insert', [{ range: sel, text: '\n' + html + '\n' }]);
}
}
return;
}
const snippet = SNIPPETS.find(s => s.id === snippetId);
if (!snippet || !monacoEditorRef.current || !monacoRef.current) return;
applySnippet(monacoEditorRef.current, snippet, monacoRef.current);
}, []);
}, [config]);
const handleVideoCardInsert = useCallback((video: PickerVideo) => {
const adminUrl = config
@ -622,6 +794,82 @@ export default function DocsPage() {
setVideoPickerOpen(false);
}, [config]);
const handleDonateInsert = useCallback((result: DonateInsertResult) => {
const uid = Date.now().toString(36);
let html = '';
if (result.variant === 'simple') {
html = [
'<div style="text-align: center; padding: 40px 20px; background: linear-gradient(135deg, #2d1b69, #1a1a2e); border-radius: 12px; margin: 16px 0;">',
' <p style="font-size: 48px; margin: 0;">&#x2764;&#xFE0F;</p>',
' <h2 style="color: #fff; margin: 12px 0;">Support Our Cause</h2>',
' <p style="color: rgba(255,255,255,0.8); margin-bottom: 24px;">Your contribution helps us create lasting change in our community.</p>',
' <a href="/donate" style="display: inline-block; padding: 14px 36px; background: #eb2f96; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 1.1rem;">Donate Now</a>',
'</div>',
].join('\n');
} else if (result.variant === 'set-amount') {
const cents = result.amount || 2500;
const dollars = (cents / 100).toFixed(0);
html = generateInlineDonateHtml({
uid,
heading: `Donate $${dollars}`,
description: 'Make a meaningful impact with your generous contribution.',
amounts: [cents],
preSelectedCents: cents,
showAmountPicker: false,
});
} else {
const cfg = result.config;
const title = cfg?.donationPageTitle || 'Support Our Cause';
const desc = cfg?.donationPageDescription || 'Every contribution makes a difference. Choose an amount below.';
const amounts = cfg?.donationSuggestedAmounts?.length
? cfg.donationSuggestedAmounts
: [1000, 2500, 5000, 10000];
html = generateInlineDonateHtml({
uid,
heading: title,
description: desc,
amounts,
preSelectedCents: null,
showAmountPicker: true,
});
}
const ed = monacoEditorRef.current;
if (ed) {
const sel = ed.getSelection();
if (sel) {
ed.executeEdits('donate-block-insert', [{ range: sel, text: '\n' + html + '\n' }]);
}
}
}, []);
const handleProductInsert = useCallback((result: ProductInsertResult) => {
const uid = Date.now().toString(36);
const p = result.product;
const isSoldOut = p.maxPurchases !== null && p.purchaseCount >= (p.maxPurchases ?? 0);
const html = generateInlineProductHtml({
uid,
productId: p.id,
title: p.title,
description: p.description || '',
priceCents: p.priceCAD,
type: p.type,
imageUrl: p.imageUrl,
isSoldOut,
});
const ed = monacoEditorRef.current;
if (ed) {
const sel = ed.getSelection();
if (sel) {
ed.executeEdits('product-block-insert', [{ range: sel, text: '\n' + html + '\n' }]);
}
}
}, []);
const handleCtxMenuClick = useCallback((snippetId: string) => {
setCtxMenu(null);
handleToolbarSnippet(snippetId);
@ -1540,7 +1788,7 @@ export default function DocsPage() {
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'insert').map(s => ({
key: s.id,
label: s.label,
icon: s.id === 'video-card' ? <PlayCircleOutlined /> : s.id === 'link' ? <LinkOutlined /> : s.id === 'image' ? <PictureOutlined /> : s.id === 'table' ? <TableOutlined /> : <PlusOutlined />,
icon: s.id === 'video-card' ? <PlayCircleOutlined /> : s.id === 'donate-button' ? <HeartOutlined /> : s.id === 'pricing-table' ? <CrownOutlined /> : s.id === 'product-card' ? <ShoppingCartOutlined /> : s.id === 'link' ? <LinkOutlined /> : s.id === 'image' ? <PictureOutlined /> : s.id === 'table' ? <TableOutlined /> : <PlusOutlined />,
onClick: () => handleToolbarSnippet(s.id),
})) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
@ -1697,6 +1945,20 @@ export default function DocsPage() {
title="Insert Video Card"
/>
{/* Donate Block Picker Modal */}
<DonateInsertModal
open={donateInsertOpen}
onClose={() => setDonateInsertOpen(false)}
onInsert={handleDonateInsert}
/>
{/* Product Card Picker Modal */}
<ProductInsertModal
open={productInsertOpen}
onClose={() => setProductInsertOpen(false)}
onInsert={handleProductInsert}
/>
{/* Custom right-click context menu with submenus */}
{ctxMenu && (
<div

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Card, Statistic, Row, Col, Button, Space, Tag, message } from 'antd';
import { Card, Statistic, Row, Col, Button, Space, Tag, message, Popconfirm } from 'antd';
import {
PauseCircleOutlined,
PlayCircleOutlined,
@ -74,20 +74,29 @@ export default function EmailQueuePage() {
<Button icon={<ReloadOutlined />} onClick={fetchStats} loading={loading}>
Refresh
</Button>
<Button
icon={stats?.paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
onClick={handlePauseResume}
loading={actionLoading}
<Popconfirm
title={stats?.paused ? 'Resume email queue?' : 'Pause email queue?'}
onConfirm={handlePauseResume}
>
{stats?.paused ? 'Resume' : 'Pause'}
</Button>
<Button
icon={<DeleteOutlined />}
onClick={handleClean}
loading={actionLoading}
<Button
icon={stats?.paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
loading={actionLoading}
>
{stats?.paused ? 'Resume' : 'Pause'}
</Button>
</Popconfirm>
<Popconfirm
title="Clean completed jobs?"
description="This permanently deletes job records."
onConfirm={handleClean}
>
Clean Old Jobs
</Button>
<Button
icon={<DeleteOutlined />}
loading={actionLoading}
>
Clean Old Jobs
</Button>
</Popconfirm>
</Space>
), [stats, loading, actionLoading, fetchStats, handlePauseResume, handleClean]);

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Table,
Button,
@ -250,38 +250,10 @@ export default function EmailTemplatesPage() {
},
];
const headerActions = useMemo(() => (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<Input
placeholder="Search by name or key..."
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
style={{ width: 200 }}
allowClear
size="small"
/>
<Select
value={categoryFilter}
onChange={setCategoryFilter}
options={categoryOptions}
style={{ width: 150 }}
size="small"
/>
<Select
value={activeFilter}
onChange={setActiveFilter}
options={activeOptions}
style={{ width: 120 }}
size="small"
/>
</div>
), [search, categoryFilter, activeFilter]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
setPageHeader({ title: 'Email Templates', actions: headerActions });
setPageHeader({ title: 'Email Templates' });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
}, [setPageHeader]);
// If editing a template, show the editor instead of the list
if (editingTemplateId) {
@ -295,6 +267,31 @@ export default function EmailTemplatesPage() {
return (
<div style={{ padding: '24px' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
<Input
placeholder="Search by name or key..."
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
style={{ width: 200 }}
allowClear
size="small"
/>
<Select
value={categoryFilter}
onChange={setCategoryFilter}
options={categoryOptions}
style={{ width: 150 }}
size="small"
/>
<Select
value={activeFilter}
onChange={setActiveFilter}
options={activeOptions}
style={{ width: 120 }}
size="small"
/>
</div>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Table
columns={columns}

View File

@ -10,7 +10,6 @@ import {
Form,
Popconfirm,
message,
Typography,
Row,
Col,
Radio,
@ -26,6 +25,7 @@ import {
SettingOutlined,
SyncOutlined,
BuildOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
@ -35,7 +35,6 @@ import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
import LandingPageEditor from '@/components/landing-pages/LandingPageEditor';
import type { LandingPage, EditorMode, LandingPagesListResponse, LandingPagesListParams, AppOutletContext } from '@/types/api';
const { Title } = Typography;
const { TextArea } = Input;
const publishedOptions = [
@ -187,6 +186,33 @@ export default function LandingPagesPage() {
}
};
// Critical MkDocs override pages that need a confirmation before editing
const CRITICAL_SLUGS = ['main', 'custom'];
const handleEditClick = (page: LandingPage) => {
if (CRITICAL_SLUGS.includes(page.slug)) {
Modal.confirm({
title: 'Edit critical MkDocs template?',
icon: <ExclamationCircleOutlined />,
content: (
<div>
<p>
<strong>{page.title}</strong> ({page.mkdocsPath || page.slug}) is a critical MkDocs
override template. Changes to this file affect the entire documentation site.
</p>
<p>Are you sure you want to edit it?</p>
</div>
),
okText: 'Edit',
okType: 'danger',
cancelText: 'Cancel',
onOk: () => setEditingPageId(page.id),
});
} else {
setEditingPageId(page.id);
}
};
const openSettings = (page: LandingPage) => {
setEditingPage(page);
settingsForm.setFieldsValue({
@ -272,7 +298,7 @@ export default function LandingPagesPage() {
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => setEditingPageId(record.id)}
onClick={() => handleEditClick(record)}
title={record.editorMode === 'CODE' ? 'Edit code' : 'Edit in builder'}
/>
<Button
@ -291,14 +317,25 @@ export default function LandingPagesPage() {
title="View page"
/>
)}
<Button
type="link"
size="small"
onClick={() => handleTogglePublished(record)}
title={record.published ? 'Unpublish' : 'Publish'}
>
{record.published ? 'Unpublish' : 'Publish'}
</Button>
{record.published ? (
<Popconfirm
title="Unpublish this page?"
description="It will no longer be publicly accessible."
onConfirm={() => handleTogglePublished(record)}
>
<Button type="link" size="small">
Unpublish
</Button>
</Popconfirm>
) : (
<Button
type="link"
size="small"
onClick={() => handleTogglePublished(record)}
>
Publish
</Button>
)}
<Popconfirm
title="Delete this page?"
description="This action cannot be undone."
@ -311,12 +348,12 @@ export default function LandingPagesPage() {
},
];
// Set fullBleed when editor is open to remove AppLayout padding/margin
// Set fullBleed when editor is open, title when in list mode
useEffect(() => {
if (editingPageId) {
setPageHeader({ fullBleed: true });
} else {
setPageHeader(null);
setPageHeader({ title: 'Landing Pages' });
}
return () => setPageHeader(null);
}, [editingPageId, setPageHeader]);
@ -336,12 +373,7 @@ export default function LandingPagesPage() {
return (
<>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Title level={4} style={{ margin: 0 }}>
Landing Pages
</Title>
</Col>
<Row justify="end" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Space>
{isSuperAdmin && (
@ -414,6 +446,7 @@ export default function LandingPagesPage() {
showTotal: (total) => `${total} pages`,
}}
onChange={handleTableChange}
locale={{ emptyText: 'No landing pages yet. Create your first page to get started.' }}
/>
{/* Create Modal */}
@ -434,7 +467,7 @@ export default function LandingPagesPage() {
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input />
<Input placeholder="e.g. Join Our Campaign" />
</Form.Item>
<Form.Item name="description" label="Description">
<TextArea rows={3} />
@ -468,7 +501,7 @@ export default function LandingPagesPage() {
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input />
<Input placeholder="e.g. Join Our Campaign" />
</Form.Item>
<Form.Item name="description" label="Description">
<TextArea rows={2} />

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Card,
Button,
@ -186,45 +186,10 @@ export default function ListmonkPage() {
? buildServiceUrl(config.listmonkSubdomain, config.domain, config.listmonkPort)
: null;
const headerActions = useMemo(() => (
<Space>
<Radio.Group
value={activeTab}
onChange={(e) => {
const tab = e.target.value as 'management' | 'admin';
setActiveTab(tab);
if (tab === 'admin') loadIframe();
}}
optionType="button"
buttonStyle="solid"
size="small"
>
<Radio.Button value="management"><SettingOutlined /> Management</Radio.Button>
<Radio.Button value="admin"><DesktopOutlined /> Listmonk Admin</Radio.Button>
</Radio.Group>
<Button
icon={<ApiOutlined />}
loading={syncing.test}
onClick={handleTestConnection}
>
Test Connection
</Button>
{listmonkAdminUrl && (
<Button
icon={<LinkOutlined />}
href={listmonkAdminUrl}
target="_blank"
>
Open Listmonk
</Button>
)}
</Space>
), [activeTab, syncing.test, handleTestConnection, listmonkAdminUrl, loadIframe]);
useEffect(() => {
setPageHeader({ title: 'Newsletter / Listmonk', actions: headerActions, fullBleed: activeTab === 'admin' });
setPageHeader({ title: 'Newsletter / Listmonk', fullBleed: activeTab === 'admin' });
return () => setPageHeader(null);
}, [setPageHeader, headerActions, activeTab]);
}, [setPageHeader, activeTab]);
if (loading) {
return (
@ -236,6 +201,40 @@ export default function ListmonkPage() {
return (
<div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
<Radio.Group
value={activeTab}
onChange={(e) => {
const tab = e.target.value as 'management' | 'admin';
setActiveTab(tab);
if (tab === 'admin') loadIframe();
}}
optionType="button"
buttonStyle="solid"
size="small"
>
<Radio.Button value="management"><SettingOutlined /> Management</Radio.Button>
<Radio.Button value="admin"><DesktopOutlined /> Listmonk Admin</Radio.Button>
</Radio.Group>
<Button
size="small"
icon={<ApiOutlined />}
loading={syncing.test}
onClick={handleTestConnection}
>
Test Connection
</Button>
{listmonkAdminUrl && (
<Button
size="small"
icon={<LinkOutlined />}
href={listmonkAdminUrl}
target="_blank"
>
Open Listmonk
</Button>
)}
</div>
{activeTab === 'management' && (
<>
<Row gutter={[16, 16]}>
@ -308,16 +307,21 @@ export default function ListmonkPage() {
</Button>
</Col>
<Col xs={24} sm={12}>
<Button
block
type="primary"
icon={<SyncOutlined />}
loading={syncing.all}
onClick={handleSyncAll}
<Popconfirm
title="Sync all contacts to Listmonk?"
onConfirm={handleSyncAll}
disabled={!status?.enabled}
>
Sync All
</Button>
<Button
block
type="primary"
icon={<SyncOutlined />}
loading={syncing.all}
disabled={!status?.enabled}
>
Sync All
</Button>
</Popconfirm>
</Col>
</Row>
</Space>

View File

@ -38,6 +38,8 @@ import {
CheckCircleOutlined,
InfoCircleOutlined,
ClockCircleOutlined,
ScissorOutlined,
EyeOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import type { UploadFile } from 'antd/es/upload';
@ -977,6 +979,12 @@ export default function LocationsPage() {
const headerActions = useMemo(() => (
<Space wrap>
<Button icon={<ScissorOutlined />} onClick={() => navigate('/app/map/cuts')}>
Cuts
</Button>
<Button icon={<EyeOutlined />} onClick={() => navigate('/map')}>
Public Map
</Button>
<Button icon={<SettingOutlined />} onClick={() => navigate('/app/map/settings')}>
Settings
</Button>
@ -1203,6 +1211,13 @@ export default function LocationsPage() {
showTotal: (total) => `${total} locations`,
}}
onChange={handleTableChange}
locale={{ emptyText: (debouncedSearch || confidenceFilter)
? 'No locations match your filters.'
: <div style={{ padding: 16 }}>
<div style={{ marginBottom: 8, color: 'rgba(255,255,255,0.45)' }}>No locations yet.</div>
<Button type="primary" icon={<UploadOutlined />} onClick={() => setImportModalOpen(true)}>Import CSV</Button>
</div>
}}
/>
</>
),

View File

@ -127,7 +127,7 @@ export default function LoginPage() {
<Title level={3} style={{ marginBottom: 4 }}>
{settings?.organizationName ?? 'Changemaker Lite'}
</Title>
<Text type="secondary">{settings?.loginSubtitle ?? 'Admin'}</Text>
<Text type="secondary">{settings?.loginSubtitle ?? 'Sign In'}</Text>
</div>
{showRegister && (
@ -251,6 +251,7 @@ export default function LoginPage() {
<Form.Item
name="password"
extra="12+ characters with uppercase, lowercase, and a digit"
rules={[
{ required: true, message: 'Please enter a password' },
{ min: 12, message: 'Password must be at least 12 characters' },

View File

@ -14,6 +14,7 @@ import {
Spin,
Space,
AutoComplete,
Switch,
} from 'antd';
import { SaveOutlined, PrinterOutlined, SearchOutlined } from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
@ -40,6 +41,7 @@ export default function MapSettingsPage() {
try {
const { data } = await api.get<MapSettings>('/map/settings');
form.setFieldsValue({
publicMapEnabled: data.publicMapEnabled ?? true,
latitude: data.latitude ? parseFloat(data.latitude) : 45.4215,
longitude: data.longitude ? parseFloat(data.longitude) : -75.6972,
zoom: data.zoom ?? 12,
@ -194,6 +196,21 @@ export default function MapSettingsPage() {
{/* Left column: Settings form */}
<Col xs={24} lg={10}>
<Form form={form} onFinish={handleSave} layout="vertical">
<Card title="Public Map Visibility" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text strong>Enable Public Map</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
When disabled, the public-facing map at /map will show an unavailable message.
</Text>
</div>
<Form.Item name="publicMapEnabled" valuePropName="checked" noStyle>
<Switch />
</Form.Item>
</div>
</Card>
<Card title="Map Center & Zoom" style={{ marginBottom: 24 }}>
<div style={{ marginBottom: 16 }}>
<AutoComplete

View File

@ -4,6 +4,7 @@ import {
Tabs,
Card,
Collapse,
ColorPicker,
Dropdown,
Form,
Input,
@ -22,6 +23,7 @@ import {
Result,
Grid,
theme,
Popconfirm,
} from 'antd';
import type { TreeDataNode } from 'antd';
import type { TreeProps } from 'antd';
@ -39,6 +41,9 @@ import {
LoadingOutlined,
WarningOutlined,
HolderOutlined,
UndoOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
} from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import { parseDocument, Document } from 'yaml';
@ -46,7 +51,7 @@ import type { ScalarTag } from 'yaml';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import type { AppOutletContext } from '@/components/AppLayout';
import type { MkDocsConfigResponse, MkDocsBuildResult, FileNode, DocsStatus, DocsConfig, Campaign } from '@/types/api';
import type { MkDocsConfigResponse, MkDocsBuildResult, FileNode, DocsStatus, DocsConfig, Campaign, HeaderConfig, HeaderNavItem } from '@/types/api';
const { Text } = Typography;
@ -287,6 +292,17 @@ export default function MkDocsSettingsPage() {
const [editorDirty, setEditorDirty] = useState(false);
const [editorSaving, setEditorSaving] = useState(false);
// Header tab state
const [headerConfig, setHeaderConfig] = useState<HeaderConfig | null>(null);
const [headerDirty, setHeaderDirty] = useState(false);
const [headerSaving, setHeaderSaving] = useState(false);
const [headerLoading, setHeaderLoading] = useState(false);
const [customLinkModalOpen, setCustomLinkModalOpen] = useState(false);
const [customLinkLabel, setCustomLinkLabel] = useState('');
const [customLinkPath, setCustomLinkPath] = useState('');
const [customLinkIcon, setCustomLinkIcon] = useState('');
const [customLinkNewTab, setCustomLinkNewTab] = useState(false);
// Build tab state
const [docsStatus, setDocsStatus] = useState<DocsStatus | null>(null);
const [docsConfig, setDocsConfig] = useState<DocsConfig | null>(null);
@ -300,10 +316,11 @@ export default function MkDocsSettingsPage() {
setLoading(true);
setError(false);
try {
const [configRes, filesRes, campaignsRes] = await Promise.all([
const [configRes, filesRes, campaignsRes, headerRes] = await Promise.all([
api.get<MkDocsConfigResponse>('/docs/mkdocs-config'),
api.get<FileNode[]>('/docs/files'),
api.get<Campaign[]>('/campaigns/public').catch(() => ({ data: [] as Campaign[] })),
api.get<HeaderConfig>('/docs/header-config').catch(() => null),
]);
const content = configRes.data.content;
setRawYaml(content);
@ -311,6 +328,9 @@ export default function MkDocsSettingsPage() {
setEditorYaml(content);
setFileTree(filesRes.data);
setCampaigns(campaignsRes.data);
if (headerRes?.data) {
setHeaderConfig(headerRes.data);
}
// Parse for settings tab
syncSettingsFromYaml(content);
@ -379,6 +399,18 @@ export default function MkDocsSettingsPage() {
return () => setPageHeader(null);
}, [setPageHeader]);
// Load Material Icons for header preview
useEffect(() => {
const id = 'material-icons-css';
if (!document.getElementById(id)) {
const link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/icon?family=Material+Icons';
document.head.appendChild(link);
}
}, []);
// --- Settings Tab: Save ---
const saveSettings = useCallback(async () => {
@ -501,6 +533,114 @@ export default function MkDocsSettingsPage() {
return () => window.removeEventListener('keydown', handler);
}, [editorDirty, saveEditor]);
// --- Header Tab ---
const updateHeaderConfig = useCallback((updater: (prev: HeaderConfig) => HeaderConfig) => {
setHeaderConfig((prev) => {
if (!prev) return prev;
return updater(prev);
});
setHeaderDirty(true);
}, []);
const toggleHeaderItem = useCallback((itemId: string, enabled: boolean) => {
updateHeaderConfig((prev) => ({
...prev,
items: prev.items.map((item) =>
item.id === itemId ? { ...item, enabled } : item
),
}));
}, [updateHeaderConfig]);
const updateHeaderItemField = useCallback((itemId: string, field: keyof HeaderNavItem, value: unknown) => {
updateHeaderConfig((prev) => ({
...prev,
items: prev.items.map((item) =>
item.id === itemId ? { ...item, [field]: value } : item
),
}));
}, [updateHeaderConfig]);
const moveHeaderItem = useCallback((itemId: string, direction: 'up' | 'down') => {
updateHeaderConfig((prev) => {
const items = [...prev.items];
const idx = items.findIndex((i) => i.id === itemId);
if (idx < 0) return prev;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= items.length) return prev;
// Swap order values
const tempOrder = items[idx]!.order;
items[idx] = { ...items[idx]!, order: items[swapIdx]!.order };
items[swapIdx] = { ...items[swapIdx]!, order: tempOrder };
// Sort by order
items.sort((a, b) => a.order - b.order);
return { ...prev, items };
});
}, [updateHeaderConfig]);
const deleteHeaderItem = useCallback((itemId: string) => {
updateHeaderConfig((prev) => ({
...prev,
items: prev.items.filter((item) => item.id !== itemId),
}));
}, [updateHeaderConfig]);
const addCustomLink = useCallback(() => {
if (!customLinkLabel.trim() || !customLinkPath.trim()) return;
updateHeaderConfig((prev) => {
const maxOrder = prev.items.reduce((max, item) => Math.max(max, item.order), -1);
return {
...prev,
items: [
...prev.items,
{
id: `custom-${Date.now()}`,
label: customLinkLabel.trim(),
path: customLinkPath.trim(),
icon: customLinkIcon.trim() || undefined,
enabled: true,
order: maxOrder + 1,
type: 'custom' as const,
openInNewTab: customLinkNewTab,
},
],
};
});
setCustomLinkLabel('');
setCustomLinkPath('');
setCustomLinkIcon('');
setCustomLinkNewTab(false);
setCustomLinkModalOpen(false);
}, [customLinkLabel, customLinkPath, customLinkIcon, customLinkNewTab, updateHeaderConfig]);
const saveHeaderConfig = useCallback(async () => {
if (!isSuperAdmin || !headerConfig) return;
setHeaderSaving(true);
try {
await api.put('/docs/header-config', headerConfig);
setHeaderDirty(false);
messageApi.success('Header config saved & template generated');
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to save header config';
messageApi.error(msg);
} finally {
setHeaderSaving(false);
}
}, [isSuperAdmin, headerConfig, messageApi]);
const resetHeaderConfig = useCallback(async () => {
setHeaderLoading(true);
try {
const res = await api.get<HeaderConfig>('/docs/header-config');
setHeaderConfig(res.data);
setHeaderDirty(false);
} catch {
messageApi.error('Failed to reload header config');
} finally {
setHeaderLoading(false);
}
}, [messageApi]);
// --- Build Tab ---
const checkStatus = useCallback(async () => {
@ -1255,6 +1395,321 @@ export default function MkDocsSettingsPage() {
</div>
),
},
{
key: 'header',
label: (
<span>
Header
{headerDirty && <Badge status="warning" style={{ marginLeft: 8 }} />}
</span>
),
children: headerConfig ? (
<div style={{ maxWidth: 900 }}>
{/* Master Toggle */}
<Card size="small" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text strong style={{ fontSize: 15 }}>Enable Header Navigation Bar</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
Adds a navigation bar above the docs header linking to your app features
</Text>
</div>
<Switch
checked={headerConfig.enabled}
onChange={(checked) => updateHeaderConfig((prev) => ({ ...prev, enabled: checked }))}
disabled={!isSuperAdmin}
/>
</div>
</Card>
{headerConfig.enabled && (
<>
{/* Style */}
<Card title="Style" size="small" style={{ marginBottom: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 16 }}>
<div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Background</Text>
<ColorPicker
value={headerConfig.style.backgroundColor}
onChange={(_, hex) => updateHeaderConfig((prev) => ({
...prev,
style: { ...prev.style, backgroundColor: hex },
}))}
showText
disabled={!isSuperAdmin}
/>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Text</Text>
<ColorPicker
value={headerConfig.style.textColor}
onChange={(_, hex) => updateHeaderConfig((prev) => ({
...prev,
style: { ...prev.style, textColor: hex },
}))}
showText
disabled={!isSuperAdmin}
/>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Hover</Text>
<Input
value={headerConfig.style.hoverColor}
onChange={(e) => updateHeaderConfig((prev) => ({
...prev,
style: { ...prev.style, hoverColor: e.target.value },
}))}
size="small"
disabled={!isSuperAdmin}
style={{ width: 180 }}
/>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Height</Text>
<Input
value={headerConfig.style.height}
onChange={(e) => updateHeaderConfig((prev) => ({
...prev,
style: { ...prev.style, height: e.target.value },
}))}
size="small"
disabled={!isSuperAdmin}
placeholder="40px"
style={{ width: 80 }}
/>
</div>
</div>
</Card>
{/* Navigation Items */}
<Card
title="Navigation Items"
size="small"
style={{ marginBottom: 16 }}
extra={
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => setCustomLinkModalOpen(true)}
disabled={!isSuperAdmin}
>
Add Custom Link
</Button>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{[...headerConfig.items].sort((a, b) => a.order - b.order).map((item, idx) => (
<div
key={item.id}
style={{
display: 'grid',
gridTemplateColumns: '40px 1fr 1.5fr 120px 80px',
gap: 8,
alignItems: 'center',
padding: '8px 12px',
background: item.enabled ? token.colorBgContainer : token.colorBgLayout,
borderRadius: 6,
border: `1px solid ${token.colorBorderSecondary}`,
opacity: item.enabled ? 1 : 0.6,
}}
>
<Switch
size="small"
checked={item.enabled}
onChange={(checked) => toggleHeaderItem(item.id, checked)}
disabled={!isSuperAdmin}
/>
<Input
size="small"
value={item.label}
onChange={(e) => updateHeaderItemField(item.id, 'label', e.target.value)}
disabled={!isSuperAdmin}
prefix={item.icon ? <span className="material-icons" style={{ fontSize: 16, color: token.colorTextSecondary }}>{item.icon}</span> : undefined}
/>
<Input
size="small"
value={item.path}
onChange={(e) => updateHeaderItemField(item.id, 'path', e.target.value)}
disabled={!isSuperAdmin}
placeholder="/path or https://..."
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
<Space size={2}>
<Tooltip title="Move up">
<Button
type="text"
size="small"
icon={<ArrowUpOutlined />}
onClick={() => moveHeaderItem(item.id, 'up')}
disabled={!isSuperAdmin || idx === 0}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
<Tooltip title="Move down">
<Button
type="text"
size="small"
icon={<ArrowDownOutlined />}
onClick={() => moveHeaderItem(item.id, 'down')}
disabled={!isSuperAdmin || idx === headerConfig.items.length - 1}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
{item.type === 'custom' && (
<Tooltip title="Delete">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => deleteHeaderItem(item.id)}
disabled={!isSuperAdmin}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
)}
</Space>
<div style={{ textAlign: 'right' }}>
<Tag color={item.type === 'builtin' ? 'blue' : 'purple'} style={{ margin: 0, fontSize: 11 }}>
{item.type}
</Tag>
</div>
</div>
))}
</div>
</Card>
{/* Preview */}
<Card title="Preview" size="small" style={{ marginBottom: 16 }}>
<div
style={{
background: headerConfig.style.backgroundColor,
minHeight: headerConfig.style.height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 6,
padding: '6px 24px',
gap: 6,
overflow: 'hidden',
}}
>
{headerConfig.items
.filter((i) => i.enabled)
.sort((a, b) => a.order - b.order)
.map((item) => (
<span
key={item.id}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 16px',
color: headerConfig.style.textColor,
fontSize: 13,
fontWeight: 600,
letterSpacing: '0.02em',
borderRadius: 6,
background: 'rgba(255, 255, 255, 0.12)',
cursor: 'default',
whiteSpace: 'nowrap',
}}
>
{item.icon && (
<span className="material-icons" style={{ fontSize: 16, color: headerConfig.style.textColor, opacity: 0.9 }}>
{item.icon}
</span>
)}
{item.label}
</span>
))}
</div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 8 }}>
On mobile (&lt;768px), labels are hidden only icons are shown. Dismiss (X) button is always hidden.</Text>
</Card>
</>
)}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '16px 0' }}>
<Popconfirm
title="Reset to defaults?"
description="This will discard unsaved changes and reload the config from disk."
onConfirm={resetHeaderConfig}
okText="Reset"
cancelText="Cancel"
>
<Button
icon={<UndoOutlined />}
disabled={!isSuperAdmin}
loading={headerLoading}
>
Reset
</Button>
</Popconfirm>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={saveHeaderConfig}
loading={headerSaving}
disabled={!headerDirty || !isSuperAdmin}
>
Save & Generate
</Button>
</div>
{/* Custom Link Modal */}
<Modal
title="Add Custom Link"
open={customLinkModalOpen}
onOk={addCustomLink}
onCancel={() => setCustomLinkModalOpen(false)}
okText="Add"
okButtonProps={{ disabled: !customLinkLabel.trim() || !customLinkPath.trim() }}
destroyOnHidden
>
<Form layout="vertical" size="small">
<Form.Item label="Label" required>
<Input
value={customLinkLabel}
onChange={(e) => setCustomLinkLabel(e.target.value)}
placeholder="e.g. Blog"
autoFocus
/>
</Form.Item>
<Form.Item label="Path or URL" required>
<Input
value={customLinkPath}
onChange={(e) => setCustomLinkPath(e.target.value)}
placeholder="e.g. /blog or https://example.com"
/>
</Form.Item>
<Form.Item label="Icon (Material Icons name)">
<Input
value={customLinkIcon}
onChange={(e) => setCustomLinkIcon(e.target.value)}
placeholder="e.g. article, open_in_new"
/>
</Form.Item>
<Form.Item>
<Switch
checked={customLinkNewTab}
onChange={setCustomLinkNewTab}
size="small"
/>
<Text style={{ marginLeft: 8, fontSize: 13 }}>Open in new tab</Text>
</Form.Item>
</Form>
</Modal>
</div>
) : (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin />
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>Loading header config...</Text>
</div>
),
},
{
key: 'build',
label: 'Build',

View File

@ -148,7 +148,7 @@ export default function ObservabilityPage() {
<AlertOutlined /> Alerts
</Radio.Button>
</Radio.Group>
<Button icon={<ReloadOutlined />} onClick={fetchAll}>
<Button icon={<ReloadOutlined />} onClick={fetchAll} loading={loading}>
Refresh
</Button>
{status?.grafana.online && (

View File

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
Card, Button, Form, Input, Space, Table, Tag, Typography, Spin, Alert, Descriptions, App,
Modal, Checkbox, Select,
Modal, Checkbox, Select, Popconfirm,
} from 'antd';
import {
CloudServerOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined,
@ -557,14 +557,19 @@ export default function PangolinPage() {
<Paragraph style={{ marginTop: 16 }}>
<strong>Step 2:</strong> After updating .env, restart the Newt container:
</Paragraph>
<Button
type="primary"
icon={<SyncOutlined />}
loading={restartLoading}
onClick={handleRestartNewt}
<Popconfirm
title="Restart Newt container?"
description="This will briefly drop all tunnel connections."
onConfirm={handleRestartNewt}
>
Restart Newt Container
</Button>
<Button
type="primary"
icon={<SyncOutlined />}
loading={restartLoading}
>
Restart Newt Container
</Button>
</Popconfirm>
{newtStatus?.ready && (
<Alert
type="success"
@ -587,14 +592,18 @@ export default function PangolinPage() {
<Card
title="Tunnel Management"
extra={
<Button
icon={<SyncOutlined />}
loading={restartLoading}
onClick={handleRestartNewt}
title="Restart Newt Container"
<Popconfirm
title="Restart Newt container?"
description="This will briefly drop all tunnel connections."
onConfirm={handleRestartNewt}
>
Restart Newt Container
</Button>
<Button
icon={<SyncOutlined />}
loading={restartLoading}
>
Restart Newt Container
</Button>
</Popconfirm>
}
>
<Alert

View File

@ -19,10 +19,11 @@ import {
DeleteOutlined,
EyeOutlined,
ReloadOutlined,
SendOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { useOutletContext } from 'react-router-dom';
import { useOutletContext, useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type {
@ -231,9 +232,16 @@ export default function RepresentativesPage() {
];
const { setPageHeader } = useOutletContext<AppOutletContext>();
const navigate = useNavigate();
const headerActions = useMemo(() => (
<Space>
<Button
icon={<SendOutlined />}
onClick={() => navigate('/app/campaigns')}
>
Campaigns
</Button>
<Input
placeholder="Postal code (e.g. T2P1N3)"
value={lookupInput}
@ -250,7 +258,7 @@ export default function RepresentativesPage() {
Lookup / Refresh
</Button>
</Space>
), [lookupInput, lookupLoading, handleLookup]);
), [lookupInput, lookupLoading, handleLookup, navigate]);
useEffect(() => {
setPageHeader({ title: 'Representatives Cache', actions: headerActions });
@ -312,6 +320,7 @@ export default function RepresentativesPage() {
showTotal: (total) => `${total} representatives`,
}}
onChange={handleTableChange}
locale={{ emptyText: 'No cached representatives. Use the lookup above to fetch and cache results.' }}
/>
{/* Detail Modal */}

View File

@ -337,6 +337,10 @@ export default function ResponsesPage() {
showSizeChanger: false,
onChange: (page) => fetchResponses(page),
}}
locale={{ emptyText: (search || statusFilter || campaignFilter)
? 'No responses match your filters.'
: 'No responses yet. Responses appear when participants submit them through campaigns.'
}}
/>
<Drawer

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useOutletContext, useLocation } from 'react-router-dom';
import {
Typography,
Tabs,
@ -14,6 +14,9 @@ import {
Space,
Segmented,
Tag,
Descriptions,
Card,
Collapse,
message,
Spin,
} from 'antd';
@ -24,6 +27,7 @@ import {
SendOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import { api } from '@/lib/api';
@ -34,6 +38,7 @@ const { Text } = Typography;
export default function SettingsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const locationState = useLocation().state as { activeTab?: string } | null;
const { settings, loading, fetchAdminSettings, updateSettings } = useSettingsStore();
const [form] = Form.useForm();
@ -224,141 +229,207 @@ export default function SettingsPage() {
{
key: 'email',
label: 'Email',
children: (
<div style={{ maxWidth: 600 }}>
{/* Sender */}
<Text strong style={{ fontSize: 15 }}>Sender</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item label="From Name" name="emailFromName" extra="The sender name used in outgoing emails">
<Input placeholder="Changemaker Lite" />
</Form.Item>
<Form.Item label="From Address" name="smtpFromAddress" extra="The sender email address (leave empty to use env default)">
<Input placeholder="noreply@cmlite.org" />
</Form.Item>
children: (() => {
const eff = settings?._effective;
const isMailhog = (settings?.smtpActiveProvider || 'mailhog') === 'mailhog';
{/* Active SMTP Provider */}
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>Active SMTP Provider</Text>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: 16 }}>
<Segmented
value={settings?.smtpActiveProvider || 'mailhog'}
options={[
{ label: 'MailHog', value: 'mailhog' },
{ label: 'Production', value: 'production' },
]}
onChange={handleProviderToggle}
size="large"
/>
<Tag
color="green"
style={{ marginLeft: 12, verticalAlign: 'middle' }}
>
{(settings?.smtpActiveProvider || 'mailhog') === 'mailhog' ? 'MailHog Active' : 'Production Active'}
</Tag>
</div>
</div>
{/* MailHog Info */}
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>MailHog (Testing)</Text>
<Divider style={{ margin: '12px 0' }} />
<div style={{ padding: '12px 16px', background: 'rgba(255,255,255,0.04)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.08)' }}>
<div><Text type="secondary">Host:</Text> <Text code>mailhog-changemaker</Text></div>
<div style={{ marginTop: 4 }}><Text type="secondary">Port:</Text> <Text code>1025</Text></div>
<div style={{ marginTop: 4 }}><Text type="secondary">Auth:</Text> <Text type="secondary">None required</Text></div>
<div style={{ marginTop: 4 }}><Text type="secondary">Web UI:</Text> <Text code>http://localhost:8025</Text></div>
</div>
</div>
{/* Production SMTP */}
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>Production SMTP</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item label="SMTP Host" name="smtpHost" extra="Production mail server hostname">
<Input placeholder="smtp.protonmail.ch" />
</Form.Item>
<Form.Item label="SMTP Port" name="smtpPort" extra="Common ports: 587 (STARTTLS), 465 (SSL)">
<InputNumber min={0} max={65535} style={{ width: '100%' }} placeholder="587" />
</Form.Item>
<Form.Item label="SMTP User" name="smtpUser">
<Input placeholder="user@example.com" />
</Form.Item>
<Form.Item label="SMTP Password" name="smtpPass">
<Input.Password placeholder="SMTP password or app-specific password" />
</Form.Item>
</div>
{/* Test Mode */}
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>Test Mode</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item
label="Enable Test Mode"
name="emailTestMode"
valuePropName="checked"
extra="When enabled, all emails are redirected to the test recipient"
>
<Switch />
</Form.Item>
<Form.Item label="Test Recipient" name="testEmailRecipient" extra="All emails will be sent to this address when test mode is on">
<Input placeholder="admin@cmlite.org" />
</Form.Item>
</div>
{/* Test Actions */}
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>Test Actions</Text>
<Divider style={{ margin: '12px 0' }} />
<Alert
type="info"
message="Tests run against the active provider. Save production credentials before switching."
showIcon
style={{ marginBottom: 16 }}
/>
<Space>
<Button
icon={<ThunderboltOutlined />}
loading={testingConnection}
onClick={handleTestConnection}
>
Test Connection
</Button>
<Button
icon={<SendOutlined />}
loading={sendingTest}
onClick={handleSendTest}
>
Send Test Email
</Button>
</Space>
{connectionResult && (
<Alert
type={connectionResult.success ? 'success' : 'error'}
message={connectionResult.message}
icon={connectionResult.success ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
showIcon
style={{ marginTop: 12 }}
/>
return (
<div style={{ maxWidth: 600 }}>
{/* Current Configuration Summary */}
{eff && (
<Card size="small" style={{ marginBottom: 24 }}>
<Descriptions
title={
<Space>
<InfoCircleOutlined />
<span>Current Email Configuration</span>
</Space>
}
column={1}
size="small"
>
<Descriptions.Item label="Provider">
<Tag color={eff.provider === 'mailhog' ? 'orange' : 'green'}>
{eff.provider === 'mailhog' ? 'MailHog' : 'Production'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Server">
<Text code>{eff.host}:{eff.port}</Text>
</Descriptions.Item>
<Descriptions.Item label="From">
<Text>{`"${eff.fromName}" <${eff.fromAddress}>`}</Text>
</Descriptions.Item>
<Descriptions.Item label="Authentication">
{eff.user ? (
<Text><CheckCircleOutlined style={{ color: '#52c41a', marginRight: 4 }} />{eff.user}</Text>
) : (
<Text type="secondary">None</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="Test Mode">
{eff.testMode ? (
<Text><Tag color="orange">ON</Tag> redirecting to {eff.testRecipient}</Text>
) : (
<Tag color="green">OFF</Tag>
)}
</Descriptions.Item>
</Descriptions>
</Card>
)}
{sendResult && (
{/* Sender */}
<Text strong style={{ fontSize: 15 }}>Sender</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item label="From Name" name="emailFromName" extra="The sender name used in outgoing emails">
<Input placeholder="Changemaker Lite" />
</Form.Item>
<Form.Item label="From Address" name="smtpFromAddress" extra="The sender email address">
<Input placeholder="noreply@cmlite.org" />
</Form.Item>
{/* Active SMTP Provider */}
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>Active SMTP Provider</Text>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: 16 }}>
<Segmented
value={settings?.smtpActiveProvider || 'mailhog'}
options={[
{ label: 'MailHog', value: 'mailhog' },
{ label: 'Production', value: 'production' },
]}
onChange={handleProviderToggle}
size="large"
/>
<Tag
color="green"
style={{ marginLeft: 12, verticalAlign: 'middle' }}
>
{isMailhog ? 'MailHog Active' : 'Production Active'}
</Tag>
</div>
</div>
{/* MailHog Info */}
{isMailhog && (
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>MailHog (Testing)</Text>
<Divider style={{ margin: '12px 0' }} />
<div style={{ padding: '12px 16px', background: 'rgba(255,255,255,0.04)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.08)' }}>
<div><Text type="secondary">Host:</Text> <Text code>mailhog-changemaker</Text></div>
<div style={{ marginTop: 4 }}><Text type="secondary">Port:</Text> <Text code>1025</Text></div>
<div style={{ marginTop: 4 }}><Text type="secondary">Auth:</Text> <Text type="secondary">None required</Text></div>
<div style={{ marginTop: 4 }}><Text type="secondary">Web UI:</Text> <Text code>http://localhost:8025</Text></div>
</div>
</div>
)}
{/* Production SMTP */}
<div style={{ marginTop: 24, opacity: isMailhog ? 0.45 : 1, pointerEvents: isMailhog ? 'none' : 'auto' }}>
<Collapse
defaultActiveKey={!isMailhog ? ['smtp'] : []}
items={[{
key: 'smtp',
label: (
<Space>
<Text strong>Production SMTP Credentials</Text>
{isMailhog && <Tag>Not active</Tag>}
</Space>
),
children: (
<div>
<Form.Item label="SMTP Host" name="smtpHost" extra="Production mail server hostname">
<Input placeholder="smtp.protonmail.ch" />
</Form.Item>
<Form.Item label="SMTP Port" name="smtpPort" extra="Common ports: 587 (STARTTLS), 465 (SSL)">
<InputNumber min={0} max={65535} style={{ width: '100%' }} placeholder="587" />
</Form.Item>
<Form.Item label="SMTP User" name="smtpUser">
<Input placeholder="user@example.com" />
</Form.Item>
<Form.Item label="SMTP Password" name="smtpPass">
<Input.Password placeholder="SMTP password or app-specific password" />
</Form.Item>
</div>
),
}]}
/>
</div>
{/* Test Mode */}
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>Test Mode</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item
label="Enable Test Mode"
name="emailTestMode"
valuePropName="checked"
extra="When enabled, all emails are redirected to the test recipient"
>
<Switch />
</Form.Item>
<Form.Item label="Test Recipient" name="testEmailRecipient" extra="All emails will be sent to this address when test mode is on">
<Input placeholder="admin@cmlite.org" />
</Form.Item>
</div>
{/* Test Actions */}
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>Test Actions</Text>
<Divider style={{ margin: '12px 0' }} />
<Alert
type={sendResult.success ? 'success' : 'error'}
message={
sendResult.success
? `Test email sent to ${sendResult.recipient}${sendResult.testMode ? ' (test mode)' : ''}`
: 'Failed to send test email'
type="info"
message={eff
? `Testing against ${eff.provider === 'mailhog' ? 'MailHog' : 'Production'} (${eff.host}:${eff.port})`
: 'Tests run against the active provider. Save production credentials before switching.'
}
description={sendResult.messageId ? `Message ID: ${sendResult.messageId}` : undefined}
showIcon
style={{ marginTop: 12 }}
style={{ marginBottom: 16 }}
/>
)}
<Space>
<Button
icon={<ThunderboltOutlined />}
loading={testingConnection}
onClick={handleTestConnection}
>
Test Connection
</Button>
<Button
icon={<SendOutlined />}
loading={sendingTest}
onClick={handleSendTest}
>
Send Test Email
</Button>
</Space>
{connectionResult && (
<Alert
type={connectionResult.success ? 'success' : 'error'}
message={connectionResult.message}
icon={connectionResult.success ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
showIcon
style={{ marginTop: 12 }}
/>
)}
{sendResult && (
<Alert
type={sendResult.success ? 'success' : 'error'}
message={
sendResult.success
? `Test email sent to ${sendResult.recipient}${sendResult.testMode ? ' (test mode)' : ''}`
: 'Failed to send test email'
}
description={sendResult.messageId ? `Message ID: ${sendResult.messageId}` : undefined}
showIcon
style={{ marginTop: 12 }}
/>
)}
</div>
</div>
</div>
),
);
})(),
},
{
key: 'registration',
@ -421,6 +492,15 @@ export default function SettingsPage() {
<Form.Item label="Enable Landing Pages" name="enableLandingPages" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label="Enable Media Library" name="enableMediaFeatures" valuePropName="checked" extra="Video library, public gallery, analytics, and scheduled publishing">
<Switch />
</Form.Item>
<Form.Item label="Enable Payments" name="enablePayments" valuePropName="checked" extra="Stripe-powered subscriptions, products, and donations">
<Switch />
</Form.Item>
<Form.Item label="Enable Gallery Ads" name="enableGalleryAds" valuePropName="checked" extra="Promotional cards inserted into the public video gallery">
<Switch />
</Form.Item>
</div>
),
},
@ -428,7 +508,7 @@ export default function SettingsPage() {
return (
<Form form={form} layout="vertical">
<Tabs items={items} />
<Tabs items={items} defaultActiveKey={locationState?.activeTab || 'organization'} />
<div style={{ marginTop: 24 }}>
<Button type="primary" icon={<SaveOutlined />} size="large" onClick={handleSave}>
Save Settings

View File

@ -24,6 +24,7 @@ import {
Segmented,
Checkbox,
Alert,
Tooltip,
} from 'antd';
import {
PlusOutlined,
@ -39,7 +40,7 @@ import {
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { useOutletContext } from 'react-router-dom';
import { useOutletContext, useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type {
@ -508,26 +509,28 @@ export default function ShiftsPage() {
width: 120,
render: (_: unknown, record: Shift) => (
<Space>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={(e) => { e.stopPropagation(); handleEditShift(record); }}
title="Edit"
/>
<Tooltip title="Edit">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={(e) => { e.stopPropagation(); handleEditShift(record); }}
/>
</Tooltip>
<Popconfirm
title="Delete this shift?"
onConfirm={(e) => { e?.stopPropagation(); handleDelete(record.id); }}
onCancel={(e) => e?.stopPropagation()}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
title="Delete"
/>
<Tooltip title="Delete">
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
/>
</Tooltip>
</Popconfirm>
</Space>
),
@ -578,7 +581,9 @@ export default function ShiftsPage() {
render: (_: unknown, record: ShiftSignup) =>
record.status === 'CONFIRMED' ? (
<Popconfirm title="Remove this volunteer?" onConfirm={() => handleRemoveSignup(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} title="Remove" />
<Tooltip title="Remove">
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
) : (
<Tag color="red">Cancelled</Tag>
@ -763,19 +768,28 @@ export default function ShiftsPage() {
);
const { setPageHeader } = useOutletContext<AppOutletContext>();
const navigate = useNavigate();
const headerActions = useMemo(() => (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
setCreateDrawerOpen(true);
}}
>
Create Shift
</Button>
), [createForm]);
<Space>
<Button
icon={<TeamOutlined />}
onClick={() => navigate('/app/map/canvass')}
>
Canvass Dashboard
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
setCreateDrawerOpen(true);
}}
>
Create Shift
</Button>
</Space>
), [createForm, navigate]);
useEffect(() => {
setPageHeader({ title: 'Shifts', actions: headerActions });
@ -888,6 +902,13 @@ export default function ShiftsPage() {
onClick: () => openSignups(record),
style: { cursor: 'pointer' },
})}
locale={{ emptyText: (debouncedSearch || statusFilter)
? 'No shifts match your filters.'
: <div style={{ padding: 16 }}>
<div style={{ marginBottom: 8, color: 'rgba(255,255,255,0.45)' }}>No shifts yet.</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { createForm.resetFields(); setCreateDrawerOpen(true); }}>Create Shift</Button>
</div>
}}
/>
</>
),
@ -1010,13 +1031,18 @@ export default function ShiftsPage() {
setAddName('');
}}
extra={
<Button
icon={<MailOutlined />}
onClick={handleEmailAll}
<Popconfirm
title={`Email all ${signups.filter((s) => s.status === 'CONFIRMED').length} volunteer(s)?`}
onConfirm={handleEmailAll}
disabled={signups.filter((s) => s.status === 'CONFIRMED').length === 0}
>
Email All
</Button>
<Button
icon={<MailOutlined />}
disabled={signups.filter((s) => s.status === 'CONFIRMED').length === 0}
>
Email All
</Button>
</Popconfirm>
}
>
{signupsShift && (
@ -1050,6 +1076,7 @@ export default function ShiftsPage() {
loading={signupsLoading}
pagination={false}
size="small"
locale={{ emptyText: 'No volunteers signed up yet.' }}
/>
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
Table,
Button,
@ -11,7 +12,6 @@ import {
InputNumber,
Popconfirm,
message,
Typography,
Row,
Col,
DatePicker,
@ -38,9 +38,9 @@ import type {
UsersListParams,
CreateUserPayload,
UpdateUserPayload,
AppOutletContext,
} from '@/types/api';
const { Title } = Typography;
const roleColors: Record<UserRole, string> = {
SUPER_ADMIN: 'red',
@ -77,6 +77,7 @@ const statusOptions: { value: UserStatus; label: string }[] = [
];
export default function UsersPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const [users, setUsers] = useState<User[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
@ -94,6 +95,11 @@ export default function UsersPage() {
const [rejectingUser, setRejectingUser] = useState<User | null>(null);
const [rejectReason, setRejectReason] = useState('');
useEffect(() => {
setPageHeader({ title: 'Users' });
return () => setPageHeader(null);
}, [setPageHeader]);
const getActiveDrawerWidth = () => {
if (createDrawerOpen) return 520;
if (editDrawerOpen) return 520;
@ -349,14 +355,9 @@ export default function UsersPage() {
>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Space>
<Title level={4} style={{ margin: 0 }}>
Users
</Title>
{pendingCount > 0 && (
<Badge count={pendingCount} style={{ backgroundColor: '#faad14' }} />
)}
</Space>
{pendingCount > 0 && (
<Badge count={pendingCount} style={{ backgroundColor: '#faad14' }} />
)}
</Col>
<Col>
<Button
@ -414,6 +415,7 @@ export default function UsersPage() {
showTotal: (total) => `${total} users`,
}}
onChange={handleTableChange}
locale={{ emptyText: 'No users found' }}
/>
</div>
@ -453,7 +455,7 @@ export default function UsersPage() {
{ type: 'email', message: 'Invalid email' },
]}
>
<Input />
<Input placeholder="jane@example.com" />
</Form.Item>
<Form.Item
name="password"
@ -466,10 +468,10 @@ export default function UsersPage() {
<Input.Password />
</Form.Item>
<Form.Item name="name" label="Name">
<Input />
<Input placeholder="Full Name" />
</Form.Item>
<Form.Item name="phone" label="Phone">
<Input />
<Input placeholder="+1 555 000 0000" />
</Form.Item>
<Form.Item name="roles" label="Roles" initialValue={['USER']}>
<Select
@ -545,7 +547,7 @@ export default function UsersPage() {
{ type: 'email', message: 'Invalid email' },
]}
>
<Input />
<Input placeholder="jane@example.com" />
</Form.Item>
<Form.Item
name="password"
@ -555,10 +557,10 @@ export default function UsersPage() {
<Input.Password />
</Form.Item>
<Form.Item name="name" label="Name">
<Input />
<Input placeholder="Full Name" />
</Form.Item>
<Form.Item name="phone" label="Phone">
<Input />
<Input placeholder="+1 555 000 0000" />
</Form.Item>
<Form.Item name="roles" label="Roles">
<Select

View File

@ -0,0 +1,707 @@
import { useEffect, useState, useCallback } from 'react';
import {
Card, Row, Col, Statistic, Table, Tag, Tabs, Select, DatePicker,
Button, Space, Spin, App, Progress, Segmented, Typography, Grid, Empty,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
MailOutlined,
TeamOutlined,
MessageOutlined,
RiseOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { useOutletContext } from 'react-router-dom';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell, AreaChart, Area, Legend,
} from 'recharts';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type {
CampaignOverviewStats, CampaignEffectivenessRow,
RepEffectivenessData, RepEffectivenessRow,
GeoBreakdownData, GeoBreakdownRow,
FunnelStage, ActivityTrendsData,
CampaignStatus,
} from '@/types/api';
import {
GOVERNMENT_LEVEL_COLORS,
GOVERNMENT_LEVEL_LABELS,
} from '@/types/api';
const { RangePicker } = DatePicker;
const { Text } = Typography;
const STATUS_COLORS: Record<CampaignStatus, string> = {
ACTIVE: 'green',
DRAFT: 'default',
PAUSED: 'orange',
ARCHIVED: 'purple',
};
const CHART_COLORS = ['#5B8FF9', '#5AD8A6', '#F6BD16', '#E86452', '#6DC8EC', '#945FB9'];
const LEVEL_PIE_COLORS: Record<string, string> = {
FEDERAL: '#1890ff',
PROVINCIAL: '#722ed1',
MUNICIPAL: '#52c41a',
SCHOOL_BOARD: '#fa8c16',
};
export default function CampaignEffectivenessPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { message } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
// Filters
const [campaignId, setCampaignId] = useState<string | undefined>();
const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(null);
const [campaigns, setCampaigns] = useState<Array<{ id: string; title: string }>>([]);
// Data
const [overview, setOverview] = useState<CampaignOverviewStats | null>(null);
const [repData, setRepData] = useState<RepEffectivenessData | null>(null);
const [geoData, setGeoData] = useState<GeoBreakdownData | null>(null);
const [funnelData, setFunnelData] = useState<FunnelStage[] | null>(null);
const [trendsData, setTrendsData] = useState<ActivityTrendsData | null>(null);
const [loading, setLoading] = useState(true);
// Tab-specific controls
const [geoGroupBy, setGeoGroupBy] = useState<'province' | 'city' | 'postalCode'>('province');
const [trendGranularity, setTrendGranularity] = useState<'day' | 'week'>('day');
const [activeTab, setActiveTab] = useState('performance');
useEffect(() => {
setPageHeader({ title: 'Campaign Effectiveness' });
return () => setPageHeader(null);
}, [setPageHeader]);
const buildParams = useCallback(() => {
const params: Record<string, string> = {};
if (campaignId) params.campaignId = campaignId;
if (dateRange?.[0]) params.dateFrom = dateRange[0].toISOString();
if (dateRange?.[1]) params.dateTo = dateRange[1].toISOString();
return params;
}, [campaignId, dateRange]);
const loadAllData = useCallback(async () => {
setLoading(true);
try {
const params = buildParams();
const [overviewRes, repRes, geoRes, funnelRes, trendsRes] = await Promise.all([
api.get('/influence/effectiveness/overview', { params }),
api.get('/influence/effectiveness/representatives', { params: { ...params, limit: 50 } }),
api.get('/influence/effectiveness/geographic', { params: { ...params, groupBy: geoGroupBy, limit: 30 } }),
api.get('/influence/effectiveness/funnel', { params }),
api.get('/influence/effectiveness/trends', { params: { ...params, granularity: trendGranularity } }),
]);
setOverview(overviewRes.data);
setRepData(repRes.data);
setGeoData(geoRes.data);
setFunnelData(funnelRes.data);
setTrendsData(trendsRes.data);
// Populate campaign dropdown from overview data
if (overviewRes.data?.campaigns) {
setCampaigns(overviewRes.data.campaigns.map((c: CampaignEffectivenessRow) => ({
id: c.campaignId,
title: c.title,
})));
}
} catch {
message.error('Failed to load effectiveness data');
} finally {
setLoading(false);
}
}, [buildParams, geoGroupBy, trendGranularity, message]);
useEffect(() => {
loadAllData();
}, [loadAllData]);
// Reload geographic data when groupBy changes
const loadGeoData = useCallback(async (groupBy: string) => {
try {
const params = buildParams();
const res = await api.get('/influence/effectiveness/geographic', {
params: { ...params, groupBy, limit: 30 },
});
setGeoData(res.data);
} catch {
message.error('Failed to load geographic data');
}
}, [buildParams, message]);
// Reload trends when granularity changes
const loadTrendsData = useCallback(async (granularity: string) => {
try {
const params = buildParams();
const res = await api.get('/influence/effectiveness/trends', {
params: { ...params, granularity },
});
setTrendsData(res.data);
} catch {
message.error('Failed to load trends data');
}
}, [buildParams, message]);
if (loading && !overview) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
<Spin size="large" />
</div>
);
}
const { summary } = overview || { summary: { totalEmails: 0, totalResponses: 0, totalCalls: 0, activeCampaigns: 0, totalCampaigns: 0, avgResponseRate: 0 } };
// --- Tab content components ---
const performanceColumns: ColumnsType<CampaignEffectivenessRow> = [
{
title: 'Campaign',
dataIndex: 'title',
key: 'title',
ellipsis: true,
width: isMobile ? 150 : undefined,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 90,
render: (status: CampaignStatus) => (
<Tag color={STATUS_COLORS[status]}>{status}</Tag>
),
},
{
title: 'Emails',
dataIndex: 'emailTotal',
key: 'emailTotal',
sorter: (a, b) => a.emailTotal - b.emailTotal,
width: 90,
align: 'right' as const,
},
{
title: 'Responses',
dataIndex: 'approvedResponses',
key: 'approvedResponses',
sorter: (a, b) => a.approvedResponses - b.approvedResponses,
width: 100,
align: 'right' as const,
},
{
title: 'Calls',
dataIndex: 'callCount',
key: 'callCount',
sorter: (a, b) => a.callCount - b.callCount,
width: 80,
align: 'right' as const,
},
{
title: 'Response Rate',
dataIndex: 'responseRate',
key: 'responseRate',
sorter: (a, b) => a.responseRate - b.responseRate,
width: 140,
render: (rate: number) => (
<Progress
percent={Math.round(rate * 100)}
size="small"
status={rate > 0.1 ? 'active' : 'normal'}
strokeColor={rate > 0.1 ? '#52c41a' : undefined}
/>
),
},
];
const repColumns: ColumnsType<RepEffectivenessRow> = [
{
title: 'Representative',
dataIndex: 'name',
key: 'name',
ellipsis: true,
width: isMobile ? 150 : undefined,
},
{
title: 'Level',
dataIndex: 'level',
key: 'level',
width: 110,
render: (level: string | null) => level ? (
<Tag color={GOVERNMENT_LEVEL_COLORS[level as keyof typeof GOVERNMENT_LEVEL_COLORS]}>
{GOVERNMENT_LEVEL_LABELS[level as keyof typeof GOVERNMENT_LEVEL_LABELS] || level}
</Tag>
) : <Text type="secondary">-</Text>,
},
{
title: 'Emails',
dataIndex: 'emailsReceived',
key: 'emailsReceived',
sorter: (a, b) => a.emailsReceived - b.emailsReceived,
width: 90,
align: 'right' as const,
},
{
title: 'Responses',
dataIndex: 'responsesGiven',
key: 'responsesGiven',
sorter: (a, b) => a.responsesGiven - b.responsesGiven,
width: 100,
align: 'right' as const,
},
{
title: 'Verified',
dataIndex: 'verifiedCount',
key: 'verifiedCount',
width: 90,
align: 'right' as const,
},
{
title: 'Rate',
dataIndex: 'responseRate',
key: 'responseRate',
sorter: (a, b) => a.responseRate - b.responseRate,
width: 120,
render: (rate: number) => (
<Progress
percent={Math.round(rate * 100)}
size="small"
strokeColor={rate > 0.2 ? '#52c41a' : rate > 0 ? '#faad14' : undefined}
/>
),
},
];
const geoColumns: ColumnsType<GeoBreakdownRow> = [
{
title: geoGroupBy === 'postalCode' ? 'Postal Code' : geoGroupBy === 'city' ? 'City' : 'Province',
dataIndex: 'key',
key: 'key',
},
{
title: 'Emails',
dataIndex: 'emailCount',
key: 'emailCount',
sorter: (a, b) => a.emailCount - b.emailCount,
width: 100,
align: 'right' as const,
},
...(geoGroupBy === 'postalCode' ? [
{
title: 'City',
dataIndex: 'city',
key: 'city',
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: 'Province',
dataIndex: 'province',
key: 'province',
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
] as ColumnsType<GeoBreakdownRow> : []),
];
// Top 10 campaigns for bar chart
const top10Campaigns = (overview?.campaigns || [])
.sort((a, b) => b.emailTotal - a.emailTotal)
.slice(0, 10)
.map((c) => ({
name: c.title.length > 25 ? c.title.substring(0, 22) + '...' : c.title,
emails: c.emailTotal,
responses: c.approvedResponses,
}));
// Funnel bar data
const funnelChartData = (funnelData || []).map((stage) => ({
name: stage.name,
count: stage.count,
percent: Math.round(stage.percentOfFirst * 100),
fill: CHART_COLORS[0],
}));
return (
<div style={{ padding: isMobile ? 12 : 0 }}>
{/* Filters */}
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<Select
placeholder="All campaigns"
allowClear
style={{ minWidth: 220 }}
value={campaignId}
onChange={setCampaignId}
options={campaigns.map((c) => ({ value: c.id, label: c.title }))}
showSearch
optionFilterProp="label"
/>
<RangePicker
value={dateRange as [Dayjs, Dayjs] | null}
onChange={(v) => setDateRange(v)}
format="YYYY-MM-DD"
/>
<Button icon={<ReloadOutlined />} onClick={loadAllData} loading={loading}>
Refresh
</Button>
</Space>
</Card>
{/* Summary stat cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Card>
<Statistic
title="Total Emails"
value={summary.totalEmails}
prefix={<MailOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title="Avg Response Rate"
value={Math.round(summary.avgResponseRate * 100)}
suffix="%"
prefix={<RiseOutlined />}
valueStyle={{ color: summary.avgResponseRate > 0.05 ? '#52c41a' : undefined }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title="Total Responses"
value={summary.totalResponses}
prefix={<MessageOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title="Active Campaigns"
value={summary.activeCampaigns}
suffix={`/ ${summary.totalCampaigns}`}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
</Row>
{/* Tabs */}
<Card>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'performance',
label: 'Performance',
children: (
<div>
{top10Campaigns.length > 0 ? (
<>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
Top Campaigns by Email Volume
</Text>
<ResponsiveContainer width="100%" height={Math.max(250, top10Campaigns.length * 35)}>
<BarChart data={top10Campaigns} layout="vertical" margin={{ left: 10, right: 30 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis type="number" stroke="rgba(255,255,255,0.5)" />
<YAxis
type="category"
dataKey="name"
width={isMobile ? 100 : 180}
tick={{ fill: 'rgba(255,255,255,0.7)', fontSize: 12 }}
/>
<Tooltip
contentStyle={{ background: '#1f1f1f', border: '1px solid #333' }}
labelStyle={{ color: '#fff' }}
/>
<Bar dataKey="emails" fill="#5B8FF9" name="Emails" radius={[0, 4, 4, 0]} />
<Bar dataKey="responses" fill="#5AD8A6" name="Responses" radius={[0, 4, 4, 0]} />
<Legend />
</BarChart>
</ResponsiveContainer>
</>
) : null}
<Text strong style={{ display: 'block', marginTop: 24, marginBottom: 12 }}>
All Campaigns
</Text>
<Table<CampaignEffectivenessRow>
dataSource={overview?.campaigns || []}
columns={performanceColumns}
rowKey="campaignId"
size="small"
scroll={{ x: 600 }}
pagination={{ pageSize: 15, showSizeChanger: false }}
/>
</div>
),
},
{
key: 'representatives',
label: 'Representatives',
children: (
<Row gutter={[16, 16]}>
{/* Pie chart - level distribution */}
{repData?.levelDistribution && repData.levelDistribution.length > 0 && (
<Col xs={24} lg={8}>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
Responses by Government Level
</Text>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={repData.levelDistribution.map((d) => ({
name: GOVERNMENT_LEVEL_LABELS[d.level as keyof typeof GOVERNMENT_LEVEL_LABELS] || d.level,
value: d.count,
}))}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={100}
dataKey="value"
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
>
{repData.levelDistribution.map((d, i) => (
<Cell
key={d.level}
fill={LEVEL_PIE_COLORS[d.level] || CHART_COLORS[i % CHART_COLORS.length]}
/>
))}
</Pie>
<Tooltip
contentStyle={{ background: '#1f1f1f', border: '1px solid #333' }}
/>
</PieChart>
</ResponsiveContainer>
</Col>
)}
{/* Rep table */}
<Col xs={24} lg={repData?.levelDistribution?.length ? 16 : 24}>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
Representative Effectiveness ({repData?.totalRepresentatives || 0} total)
</Text>
<Table<RepEffectivenessRow>
dataSource={repData?.representatives || []}
columns={repColumns}
rowKey="name"
size="small"
scroll={{ x: 600 }}
pagination={{ pageSize: 15, showSizeChanger: false }}
/>
</Col>
</Row>
),
},
{
key: 'geography',
label: 'Geography',
children: (
<div>
<Space style={{ marginBottom: 16 }}>
<Segmented
value={geoGroupBy}
onChange={(v) => {
const val = v as 'province' | 'city' | 'postalCode';
setGeoGroupBy(val);
loadGeoData(val);
}}
options={[
{ label: 'Province', value: 'province' },
{ label: 'City', value: 'city' },
{ label: 'Postal Code', value: 'postalCode' },
]}
/>
</Space>
{geoData?.data && geoData.data.length > 0 && geoGroupBy !== 'postalCode' ? (
<>
<ResponsiveContainer width="100%" height={Math.max(250, geoData.data.length * 30)}>
<BarChart data={geoData.data} layout="vertical" margin={{ left: 10, right: 30 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis type="number" stroke="rgba(255,255,255,0.5)" />
<YAxis
type="category"
dataKey="key"
width={isMobile ? 80 : 140}
tick={{ fill: 'rgba(255,255,255,0.7)', fontSize: 12 }}
/>
<Tooltip
contentStyle={{ background: '#1f1f1f', border: '1px solid #333' }}
labelStyle={{ color: '#fff' }}
/>
<Bar dataKey="emailCount" fill="#6DC8EC" name="Emails" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</>
) : null}
<Table<GeoBreakdownRow>
dataSource={geoData?.data || []}
columns={geoColumns}
rowKey="key"
size="small"
scroll={{ x: 400 }}
pagination={{ pageSize: 20, showSizeChanger: false }}
style={{ marginTop: geoGroupBy !== 'postalCode' ? 16 : 0 }}
/>
{geoData?.data?.length === 0 && (
<Empty description="No geographic data available" />
)}
</div>
),
},
{
key: 'funnel',
label: 'Funnel',
children: (
<div>
<Text strong style={{ display: 'block', marginBottom: 16 }}>
Engagement Funnel
</Text>
{funnelData && funnelData.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={funnelChartData} margin={{ left: 10, right: 30 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis
dataKey="name"
stroke="rgba(255,255,255,0.5)"
tick={{ fill: 'rgba(255,255,255,0.7)', fontSize: 11 }}
angle={isMobile ? -30 : 0}
textAnchor={isMobile ? 'end' : 'middle'}
height={isMobile ? 60 : 30}
/>
<YAxis stroke="rgba(255,255,255,0.5)" />
<Tooltip
contentStyle={{ background: '#1f1f1f', border: '1px solid #333' }}
labelStyle={{ color: '#fff' }}
formatter={(value: unknown, _name: unknown, props: unknown) => {
const v = value as number;
const p = props as { payload: { percent: number } };
return [`${v.toLocaleString()} (${p.payload.percent}%)`, 'Count'];
}}
/>
<Bar dataKey="count" fill="#5B8FF9" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
{/* Funnel stages detail */}
<Row gutter={[12, 12]} style={{ marginTop: 16 }}>
{funnelData.map((stage, i) => (
<Col xs={24} sm={12} md={8} lg={4} key={stage.name}>
<Card size="small">
<Statistic
title={stage.name}
value={stage.count}
suffix={
i > 0 ? (
<Text type="secondary" style={{ fontSize: 12 }}>
({Math.round(stage.percentOfFirst * 100)}%)
</Text>
) : null
}
/>
{i > 0 && stage.dropoff > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}>
{Math.round(stage.dropoff * 100)}% dropoff
</Text>
)}
</Card>
</Col>
))}
</Row>
</>
) : (
<Empty description="No funnel data available" />
)}
</div>
),
},
{
key: 'trends',
label: 'Trends',
children: (
<div>
<Space style={{ marginBottom: 16 }}>
<Segmented
value={trendGranularity}
onChange={(v) => {
const val = v as 'day' | 'week';
setTrendGranularity(val);
loadTrendsData(val);
}}
options={[
{ label: 'Daily', value: 'day' },
{ label: 'Weekly', value: 'week' },
]}
/>
</Space>
{trendsData?.series && trendsData.series.length > 0 ? (
<ResponsiveContainer width="100%" height={350}>
<AreaChart data={trendsData.series} margin={{ left: 10, right: 30, top: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis
dataKey="date"
stroke="rgba(255,255,255,0.5)"
tick={{ fill: 'rgba(255,255,255,0.7)', fontSize: 11 }}
tickFormatter={(d: string) => dayjs(d).format('MMM D')}
/>
<YAxis stroke="rgba(255,255,255,0.5)" />
<Tooltip
contentStyle={{ background: '#1f1f1f', border: '1px solid #333' }}
labelStyle={{ color: '#fff' }}
labelFormatter={(d) => dayjs(String(d)).format('MMM D, YYYY')}
/>
<Area
type="monotone"
dataKey="emails"
name="Emails Sent"
fill="#5B8FF9"
fillOpacity={0.3}
stroke="#5B8FF9"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="responses"
name="Responses"
fill="#5AD8A6"
fillOpacity={0.3}
stroke="#5AD8A6"
strokeWidth={2}
/>
<Legend />
</AreaChart>
</ResponsiveContainer>
) : (
<Empty description="No trend data available for this period" />
)}
{trendsData && (
<Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
Showing {trendsData.granularity} data from {dayjs(trendsData.dateFrom).format('MMM D, YYYY')} to {dayjs(trendsData.dateTo).format('MMM D, YYYY')}
</Text>
)}
</div>
),
},
]}
/>
</Card>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
Card,
Table,
@ -32,6 +33,7 @@ import {
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { mediaApi } from '@/lib/media-api';
import type { AppOutletContext } from '@/types/api';
const { Text, Paragraph } = Typography;
const { RangePicker } = DatePicker;
@ -76,6 +78,13 @@ interface WordFilter {
}
export default function CommentModerationPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
useEffect(() => {
setPageHeader({ title: 'Comment Moderation' });
return () => setPageHeader(null);
}, [setPageHeader]);
// Comments state
const [comments, setComments] = useState<CommentRecord[]>([]);
const [stats, setStats] = useState<CommentStats>({ total: 0, pending: 0, flagged: 0, hidden: 0, safe: 0 });

View File

@ -0,0 +1,561 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Button,
Tag,
Switch,
Space,
Drawer,
Form,
Input,
InputNumber,
Select,
Segmented,
DatePicker,
ColorPicker,
Card,
Statistic,
Row,
Col,
Popconfirm,
Modal,
message,
Typography,
Spin,
ConfigProvider,
theme as antTheme,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
CopyOutlined,
EyeOutlined,
ThunderboltOutlined,
PercentageOutlined,
BarChartOutlined,
} from '@ant-design/icons';
import { useOutletContext, useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/types/api';
import type { GalleryAd, AdType, AdVisibility } from '@/types/gallery-ads';
import { AD_TYPE_LABELS, AD_TYPE_COLORS, AD_VISIBILITY_LABELS, AD_VISIBILITY_COLORS } from '@/types/gallery-ads';
import FeatureGate from '@/components/FeatureGate';
import GalleryAdCard from '@/components/media/GalleryAdCard';
import dayjs from 'dayjs';
const { Text } = Typography;
const { TextArea } = Input;
const { RangePicker } = DatePicker;
interface DailyAnalytics {
date: string;
impressions: number;
clicks: number;
}
interface AdAnalytics {
daily: DailyAnalytics[];
totals: {
impressions: number;
clicks: number;
uniqueSessions: number;
ctr: number;
};
}
export default function GalleryAdsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const navigate = useNavigate();
const [ads, setAds] = useState<GalleryAd[]>([]);
const [loading, setLoading] = useState(true);
const [drawerOpen, setDrawerOpen] = useState(false);
const [editingAd, setEditingAd] = useState<GalleryAd | null>(null);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm();
// Preview state
const [previewAd, setPreviewAd] = useState<GalleryAd | null>(null);
// Analytics drawer state
const [analyticsAd, setAnalyticsAd] = useState<GalleryAd | null>(null);
const [analytics, setAnalytics] = useState<AdAnalytics | null>(null);
const [analyticsLoading, setAnalyticsLoading] = useState(false);
useEffect(() => {
setPageHeader({
title: 'Gallery Ads',
actions: (
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Create Ad
</Button>
),
});
return () => setPageHeader(null);
}, [setPageHeader]);
const fetchAds = useCallback(async () => {
try {
setLoading(true);
const { data } = await api.get('/gallery-ads/admin', { params: { limit: 100 } });
setAds(data.ads);
} catch {
message.error('Failed to load ads');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAds();
}, [fetchAds]);
const fetchAnalytics = useCallback(async (ad: GalleryAd) => {
setAnalyticsAd(ad);
setAnalyticsLoading(true);
try {
const { data } = await api.get(`/gallery-ads/admin/${ad.id}/analytics`, { params: { days: 30 } });
setAnalytics(data);
} catch {
message.error('Failed to load analytics');
} finally {
setAnalyticsLoading(false);
}
}, []);
const handleCreate = () => {
setEditingAd(null);
form.resetFields();
form.setFieldsValue({
type: 'custom',
variant: 'standard',
ctaStyle: 'primary',
visibility: 'everyone',
frequency: 6,
position: 0,
isActive: false,
});
setDrawerOpen(true);
};
const handleEdit = (ad: GalleryAd) => {
setEditingAd(ad);
form.setFieldsValue({
...ad,
schedule: ad.startDate && ad.endDate
? [dayjs(ad.startDate), dayjs(ad.endDate)]
: undefined,
});
setDrawerOpen(true);
};
const handleDuplicate = (ad: GalleryAd) => {
setEditingAd(null);
form.resetFields();
form.setFieldsValue({
...ad,
title: `${ad.title} (Copy)`,
isActive: false,
schedule: ad.startDate && ad.endDate
? [dayjs(ad.startDate), dayjs(ad.endDate)]
: undefined,
});
setDrawerOpen(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
// Convert color picker value
if (values.bgColor && typeof values.bgColor === 'object' && 'toHexString' in values.bgColor) {
values.bgColor = values.bgColor.toHexString();
}
// Convert schedule to startDate/endDate
if (values.schedule) {
values.startDate = values.schedule[0].toISOString();
values.endDate = values.schedule[1].toISOString();
} else {
values.startDate = null;
values.endDate = null;
}
delete values.schedule;
if (editingAd) {
await api.put(`/gallery-ads/admin/${editingAd.id}`, values);
message.success('Ad updated');
} else {
await api.post('/gallery-ads/admin', values);
message.success('Ad created');
}
setDrawerOpen(false);
fetchAds();
} catch (err: any) {
if (err?.response?.data?.error) {
message.error(err.response.data.error);
} else if (!err?.errorFields) {
message.error('Failed to save ad');
}
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/gallery-ads/admin/${id}`);
message.success('Ad deleted');
fetchAds();
} catch (err: any) {
message.error(err?.response?.data?.error || 'Failed to delete ad');
}
};
const handleToggleActive = async (id: number, isActive: boolean) => {
try {
await api.put(`/gallery-ads/admin/${id}`, { isActive });
setAds(prev => prev.map(a => a.id === id ? { ...a, isActive } : a));
} catch {
message.error('Failed to update ad status');
}
};
// Stats
const totalAds = ads.length;
const activeAds = ads.filter(a => a.isActive).length;
const totalImpressions = ads.reduce((sum, a) => sum + (a.impressionCount || 0), 0);
const totalClicks = ads.reduce((sum, a) => sum + (a.clickCount || 0), 0);
const columns = [
{
title: 'Type',
dataIndex: 'type',
key: 'type',
width: 110,
render: (type: AdType) => (
<Tag color={AD_TYPE_COLORS[type]}>{AD_TYPE_LABELS[type]}</Tag>
),
},
{
title: 'Title',
dataIndex: 'title',
key: 'title',
ellipsis: true,
render: (title: string, record: GalleryAd) => (
<div>
<div>{title}</div>
{record.isSystemAd && <Text type="secondary" style={{ fontSize: 11 }}>System</Text>}
{record.productId && (
<Tag
color="cyan"
style={{ fontSize: 10, marginTop: 2, cursor: 'pointer' }}
onClick={() => navigate(`/app/payments/products?highlight=${record.productId}`)}
>
Auto (Product)
</Tag>
)}
</div>
),
},
{
title: 'Visibility',
dataIndex: 'visibility',
key: 'visibility',
width: 130,
render: (vis: AdVisibility) => (
<Tag color={AD_VISIBILITY_COLORS[vis]}>{AD_VISIBILITY_LABELS[vis]}</Tag>
),
},
{
title: 'Frequency',
dataIndex: 'frequency',
key: 'frequency',
width: 100,
render: (freq: number) => `Every ${freq}`,
},
{
title: 'Impressions',
dataIndex: 'impressionCount',
key: 'impressionCount',
width: 100,
render: (count: number) => (count || 0).toLocaleString(),
},
{
title: 'Clicks',
dataIndex: 'clickCount',
key: 'clickCount',
width: 80,
render: (count: number) => (count || 0).toLocaleString(),
},
{
title: 'CTR',
key: 'ctr',
width: 70,
render: (_: unknown, record: GalleryAd) => {
const impressions = record.impressionCount || 0;
const clicks = record.clickCount || 0;
if (impressions === 0) return '-';
return `${((clicks / impressions) * 100).toFixed(1)}%`;
},
},
{
title: 'Active',
dataIndex: 'isActive',
key: 'isActive',
width: 80,
render: (active: boolean, record: GalleryAd) => (
<Switch
checked={active}
size="small"
onChange={(checked) => handleToggleActive(record.id, checked)}
/>
),
},
{
title: 'Actions',
key: 'actions',
width: 160,
render: (_: unknown, record: GalleryAd) => (
<Space size="small">
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setPreviewAd(record)} title="Preview" />
<Button type="text" size="small" icon={<BarChartOutlined />} onClick={() => fetchAnalytics(record)} title="Analytics" />
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => handleDuplicate(record)} />
{!record.isSystemAd && (
<Popconfirm title="Delete this ad?" onConfirm={() => handleDelete(record.id)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
return (
<FeatureGate feature="enableGalleryAds">
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="Total Ads" value={totalAds} prefix={<EyeOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="Active" value={activeAds} prefix={<ThunderboltOutlined />} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="Impressions" value={totalImpressions} prefix={<EyeOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="Clicks" value={totalClicks} prefix={<PercentageOutlined />} />
</Card>
</Col>
</Row>
<Table
dataSource={ads}
columns={columns}
rowKey="id"
loading={loading}
pagination={false}
size="middle"
/>
{/* Edit/Create Drawer */}
<Drawer
title={editingAd ? 'Edit Ad' : 'Create Ad'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={520}
extra={
<Button type="primary" onClick={handleSave} loading={saving}>
Save
</Button>
}
>
<Form form={form} layout="vertical">
<Form.Item name="type" label="Type" rules={[{ required: true }]}>
<Select
disabled={editingAd?.isSystemAd}
options={[
{ label: 'System', value: 'system' },
{ label: 'Subscribe', value: 'payment_subscribe' },
{ label: 'Donate', value: 'payment_donate' },
{ label: 'Shop', value: 'payment_shop' },
{ label: 'Custom', value: 'custom' },
]}
/>
</Form.Item>
<Form.Item name="variant" label="Variant">
<Segmented
options={[
{ label: 'Standard', value: 'standard' },
{ label: 'Highlight', value: 'highlight' },
{ label: 'Minimal', value: 'minimal' },
]}
/>
</Form.Item>
<Form.Item name="title" label="Title" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="subtitle" label="Subtitle">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="linkUrl" label="Link URL">
<Input placeholder="/campaigns or https://..." />
</Form.Item>
<Form.Item name="ctaText" label="CTA Button Text">
<Input placeholder="Learn More" />
</Form.Item>
<Form.Item name="ctaStyle" label="CTA Style">
<Segmented
options={[
{ label: 'Primary', value: 'primary' },
{ label: 'Outline', value: 'outline' },
{ label: 'Link', value: 'link' },
]}
/>
</Form.Item>
<Form.Item name="bgColor" label="Background Color">
<ColorPicker />
</Form.Item>
<Form.Item name="iconEmoji" label="Icon/Emoji">
<Input placeholder="e.g. megaphone icon or emoji" maxLength={10} />
</Form.Item>
<Form.Item name="imagePath" label="Image URL">
<Input placeholder="https://..." />
</Form.Item>
<Form.Item name="visibility" label="Visibility">
<Select
options={[
{ label: 'Everyone', value: 'everyone' },
{ label: 'Anonymous Only', value: 'anonymous' },
{ label: 'Logged-In Only', value: 'authenticated' },
{ label: 'Non-Subscribers', value: 'non_subscriber' },
]}
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="frequency" label="Frequency" extra="Insert every N videos" rules={[{ required: true }]}>
<InputNumber min={1} max={24} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="position" label="Priority" extra="Lower = higher priority">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="isActive" label="Active" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="schedule" label="Schedule (optional)">
<RangePicker
showTime
style={{ width: '100%' }}
placeholder={['Start Date', 'End Date']}
/>
</Form.Item>
</Form>
</Drawer>
{/* Preview Modal — renders GalleryAdCard on dark gallery background */}
<Modal
open={!!previewAd}
onCancel={() => setPreviewAd(null)}
footer={null}
width={360}
styles={{ content: { background: '#0d1b2a', padding: 24 } }}
>
{previewAd && (
<ConfigProvider
theme={{
algorithm: antTheme.darkAlgorithm,
token: {
colorBgBase: '#0d1b2a',
colorBgContainer: '#1b2838',
},
}}
>
<GalleryAdCard ad={previewAd} preview />
</ConfigProvider>
)}
</Modal>
{/* Analytics Drawer */}
<Drawer
title={analyticsAd ? `Analytics: ${analyticsAd.title}` : 'Ad Analytics'}
open={!!analyticsAd}
onClose={() => { setAnalyticsAd(null); setAnalytics(null); }}
width={520}
>
{analyticsLoading ? (
<div style={{ textAlign: 'center', padding: 48 }}><Spin /></div>
) : analytics ? (
<>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Statistic title="Impressions" value={analytics.totals.impressions} />
</Col>
<Col span={6}>
<Statistic title="Clicks" value={analytics.totals.clicks} />
</Col>
<Col span={6}>
<Statistic title="CTR" value={analytics.totals.ctr} suffix="%" />
</Col>
<Col span={6}>
<Statistic title="Unique Sessions" value={analytics.totals.uniqueSessions} />
</Col>
</Row>
<Text strong style={{ display: 'block', marginBottom: 12 }}>Daily Breakdown (Last 30 Days)</Text>
{analytics.daily.length === 0 ? (
<Text type="secondary">No data recorded yet</Text>
) : (
<Table
dataSource={analytics.daily}
rowKey="date"
pagination={false}
size="small"
columns={[
{ title: 'Date', dataIndex: 'date', key: 'date' },
{ title: 'Impressions', dataIndex: 'impressions', key: 'impressions' },
{ title: 'Clicks', dataIndex: 'clicks', key: 'clicks' },
{
title: 'CTR',
key: 'ctr',
render: (_: unknown, row: DailyAnalytics) =>
row.impressions > 0
? `${((row.clicks / row.impressions) * 100).toFixed(1)}%`
: '-',
},
]}
/>
)}
</>
) : null}
</Drawer>
</FeatureGate>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { Row, Col, Input, Select, Button, Pagination, message, Empty, Spin, Tooltip } from 'antd';
import { Row, Col, Input, Select, Button, Pagination, message, Empty, Spin, Tooltip, Modal } from 'antd';
import {
SearchOutlined,
GlobalOutlined,
@ -11,6 +11,7 @@ import {
CloudDownloadOutlined,
ThunderboltOutlined,
OrderedListOutlined,
LockOutlined,
} from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
import { mediaApi } from '@/lib/media-api';
@ -31,6 +32,7 @@ import EditVideoModal from '@/components/media/EditVideoModal';
import FetchVideosDrawer from '@/components/media/FetchVideosDrawer';
import AddToPlaylistModal from '@/components/media/AddToPlaylistModal';
import BulkAddToPlaylistModal from '@/components/media/BulkAddToPlaylistModal';
import BulkAccessLevelModal from '@/components/media/BulkAccessLevelModal';
export default function LibraryPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
@ -59,6 +61,7 @@ export default function LibraryPage() {
const [scanningShorts, setScanningShorts] = useState(false);
const [playlistVideoId, setPlaylistVideoId] = useState<number | null>(null);
const [bulkPlaylistOpen, setBulkPlaylistOpen] = useState(false);
const [bulkAccessLevelOpen, setBulkAccessLevelOpen] = useState(false);
useEffect(() => {
setPageHeader({
@ -161,14 +164,22 @@ export default function LibraryPage() {
}
};
const handleDeleteSingle = async (video: Video) => {
try {
await mediaApi.delete(`/videos/${video.id}`);
message.success('Video deleted successfully');
fetchVideos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to delete video');
}
const handleDeleteSingle = (video: Video) => {
Modal.confirm({
title: 'Delete this video?',
content: video.title || video.filename,
okText: 'Delete',
okType: 'danger',
onOk: async () => {
try {
await mediaApi.delete(`/videos/${video.id}`);
message.success('Video deleted successfully');
fetchVideos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to delete video');
}
},
});
};
const handleEdit = (video: Video) => {
@ -350,6 +361,12 @@ export default function LibraryPage() {
onClick: () => setPublishModalOpen(true),
type: 'primary',
},
{
key: 'access-level',
label: 'Access Level',
icon: <LockOutlined />,
onClick: () => setBulkAccessLevelOpen(true),
},
{
key: 'add-to-playlist',
label: 'Add to Playlist',
@ -437,6 +454,17 @@ export default function LibraryPage() {
/>
)}
<BulkAccessLevelModal
open={bulkAccessLevelOpen}
videoIds={selectedVideoIds}
onClose={() => setBulkAccessLevelOpen(false)}
onSuccess={() => {
setBulkAccessLevelOpen(false);
setSelectedVideoIds([]);
fetchVideos();
}}
/>
<BulkAddToPlaylistModal
videoIds={selectedVideoIds}
open={bulkPlaylistOpen}

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
Typography,
Table,
@ -27,10 +28,11 @@ import {
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { mediaApi } from '@/lib/media-api';
import type { AppOutletContext } from '@/types/api';
import CreatePlaylistModal from '@/components/media/CreatePlaylistModal';
import EditPlaylistModal from '@/components/media/EditPlaylistModal';
const { Title, Text } = Typography;
const { Text } = Typography;
interface PlaylistRow {
id: number;
@ -82,6 +84,13 @@ const sorterFieldMap: Record<string, string> = {
};
export default function PlaylistManagementPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
useEffect(() => {
setPageHeader({ title: 'Playlist Management' });
return () => setPageHeader(null);
}, [setPageHeader]);
// All Playlists tab state
const [playlists, setPlaylists] = useState<PlaylistRow[]>([]);
const [total, setTotal] = useState(0);
@ -489,8 +498,7 @@ export default function PlaylistManagementPage() {
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={3} style={{ margin: 0 }}>Playlist Management</Title>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', marginBottom: 16 }}>
<Button
type="primary"
icon={<PlusOutlined />}

View File

@ -0,0 +1,223 @@
import { useState, useEffect, useCallback } from 'react';
import { Table, Card, Input, Typography, App, Tag, Button, Space, Modal } from 'antd';
import { SearchOutlined, DownloadOutlined, RollbackOutlined } from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { Order, PaginationMeta, AppOutletContext } from '@/types/api';
const { Text } = Typography;
const { TextArea } = Input;
export default function DonationsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const [donations, setDonations] = useState<Order[]>([]);
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [refundModalOpen, setRefundModalOpen] = useState(false);
const [refundTarget, setRefundTarget] = useState<Order | null>(null);
const [refundReason, setRefundReason] = useState('');
const [refundLoading, setRefundLoading] = useState(false);
const { message } = App.useApp();
const fetchDonations = useCallback(async (page = 1) => {
setLoading(true);
try {
const params: Record<string, string | number> = { page, limit: 20 };
if (search) params.search = search;
const { data } = await api.get('/payments/admin/donations', { params });
setDonations(data.donations);
setPagination(data.pagination);
} catch {
message.error('Failed to load donations');
} finally {
setLoading(false);
}
}, [search, message]);
useEffect(() => { fetchDonations(); }, [fetchDonations]);
useEffect(() => {
setPageHeader({ title: 'Donations' });
return () => setPageHeader(null);
}, [setPageHeader]);
const handleRefund = async () => {
if (!refundTarget) return;
setRefundLoading(true);
try {
await api.post(`/payments/admin/donations/${refundTarget.id}/refund`, {
reason: refundReason || undefined,
});
message.success('Donation refunded successfully');
setRefundModalOpen(false);
setRefundTarget(null);
setRefundReason('');
fetchDonations(pagination.page);
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Refund failed';
message.error(msg);
} finally {
setRefundLoading(false);
}
};
const handleExport = async () => {
try {
const params: Record<string, string> = {};
if (search) params.search = search;
const { data } = await api.get('/payments/admin/donations/export', {
params,
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([data]));
const a = document.createElement('a');
a.href = url;
a.download = `donations-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
window.URL.revokeObjectURL(url);
} catch {
message.error('Failed to export donations');
}
};
const statusColors: Record<string, string> = {
PENDING: 'blue',
COMPLETED: 'green',
FAILED: 'red',
REFUNDED: 'orange',
};
const columns = [
{
title: 'Date',
dataIndex: 'createdAt',
key: 'date',
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: 'Donor',
key: 'donor',
render: (_: unknown, r: Order) => (
<div>
<div>{r.isAnonymous ? 'Anonymous' : (r.buyerName || r.buyerEmail)}</div>
{!r.isAnonymous && r.buyerName && (
<Text type="secondary" style={{ fontSize: 12 }}>{r.buyerEmail}</Text>
)}
</div>
),
},
{
title: 'Amount',
dataIndex: 'amountCAD',
key: 'amount',
render: (v: number) => <Text strong>${(v / 100).toFixed(2)}</Text>,
},
{
title: 'Message',
dataIndex: 'donorMessage',
key: 'message',
ellipsis: true,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (s: string) => <Tag color={statusColors[s] || 'default'}>{s}</Tag>,
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, r: Order) => (
<Space>
{r.status === 'COMPLETED' && (
<Button
size="small"
danger
icon={<RollbackOutlined />}
onClick={() => {
setRefundTarget(r);
setRefundReason('');
setRefundModalOpen(true);
}}
>
Refund
</Button>
)}
</Space>
),
},
];
return (
<div>
<Card style={{ marginBottom: 16 }}>
<Space wrap>
<Input
placeholder="Search by name or email"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => setSearch(e.target.value)}
onPressEnter={() => fetchDonations(1)}
style={{ width: 300 }}
/>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
Export CSV
</Button>
</Space>
</Card>
<Table
dataSource={donations}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
onChange: (p) => fetchDonations(p),
}}
/>
<Modal
title="Refund Donation"
open={refundModalOpen}
onCancel={() => { setRefundModalOpen(false); setRefundTarget(null); }}
footer={[
<Button key="cancel" onClick={() => { setRefundModalOpen(false); setRefundTarget(null); }}>
Cancel
</Button>,
<Button key="refund" danger type="primary" loading={refundLoading} onClick={handleRefund}>
Confirm Refund
</Button>,
]}
>
{refundTarget && (
<div>
<p><strong>Donor:</strong> {refundTarget.isAnonymous ? 'Anonymous' : (refundTarget.buyerName || refundTarget.buyerEmail)}</p>
{!refundTarget.isAnonymous && <p><strong>Email:</strong> {refundTarget.buyerEmail}</p>}
<p><strong>Amount:</strong> ${(refundTarget.amountCAD / 100).toFixed(2)} CAD</p>
<p><strong>Date:</strong> {new Date(refundTarget.createdAt).toLocaleString()}</p>
<div style={{ marginTop: 16 }}>
<Text strong>Reason (optional):</Text>
<TextArea
rows={3}
maxLength={500}
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
placeholder="Enter reason for refund..."
style={{ marginTop: 4 }}
/>
</div>
<p style={{ marginTop: 12, color: '#ff4d4f', fontSize: 13 }}>
This action will refund the donation via Stripe and cannot be undone.
</p>
</div>
)}
</Modal>
</div>
);
}

View File

@ -0,0 +1,325 @@
import { useState, useEffect, useCallback } from 'react';
import { Tabs, Card, Form, Input, InputNumber, Button, Switch, Space, Typography, App, Table, Tag, Popconfirm, Modal } from 'antd';
import { SyncOutlined, PlusOutlined, DeleteOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { PaymentSettings, SubscriptionPlan, AppOutletContext } from '@/types/api';
const { Text } = Typography;
function StripeSettingsTab() {
const [settings, setSettings] = useState<PaymentSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [form] = Form.useForm();
const { message } = App.useApp();
useEffect(() => {
api.get('/payments/admin/settings')
.then(({ data }) => { setSettings(data); form.setFieldsValue(data); })
.catch(() => message.error('Failed to load settings'))
.finally(() => setLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleSave = async () => {
setSaving(true);
try {
const values = await form.validateFields();
// Only send changed keys
const toSend: Record<string, unknown> = {};
for (const [k, v] of Object.entries(values)) {
if (v !== undefined && v !== '') toSend[k] = v;
}
const { data } = await api.put('/payments/admin/settings', toSend);
setSettings(data);
message.success('Settings saved');
} catch {
message.error('Failed to save settings');
} finally {
setSaving(false);
}
};
const handleTest = async () => {
setTesting(true);
try {
const { data } = await api.post('/payments/admin/settings/test-connection');
if (data.success) {
message.success('Stripe connection verified!');
} else {
message.error(`Connection failed: ${data.message}`);
}
} catch {
message.error('Failed to test connection');
} finally {
setTesting(false);
}
};
if (loading) return null;
return (
<Form form={form} layout="vertical" style={{ maxWidth: 600 }}>
<Form.Item name="stripeSecretKey" label="Secret Key">
<Input.Password placeholder={settings?.stripeSecretKey || 'sk_test_...'} />
</Form.Item>
<Form.Item name="stripePublishableKey" label="Publishable Key">
<Input placeholder="pk_test_..." />
</Form.Item>
<Form.Item name="stripeWebhookSecret" label="Webhook Secret">
<Input.Password placeholder={settings?.stripeWebhookSecret || 'whsec_...'} />
</Form.Item>
<Form.Item name="defaultCurrency" label="Default Currency">
<Input placeholder="cad" style={{ width: 100 }} />
</Form.Item>
<Space>
<Button type="primary" onClick={handleSave} loading={saving}>Save Settings</Button>
<Button icon={<CheckCircleOutlined />} onClick={handleTest} loading={testing}>Test Connection</Button>
</Space>
</Form>
);
}
function PlansTab() {
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingPlan, setEditingPlan] = useState<SubscriptionPlan | null>(null);
const [form] = Form.useForm();
const { message } = App.useApp();
const fetchPlans = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get('/payments/admin/plans');
setPlans(data);
} catch {
message.error('Failed to load plans');
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
values.priceCAD = Math.round(values.priceCAD * 100);
if (values.yearlyPriceCAD) values.yearlyPriceCAD = Math.round(values.yearlyPriceCAD * 100);
if (editingPlan) {
await api.put(`/payments/admin/plans/${editingPlan.id}`, values);
message.success('Plan updated');
} else {
await api.post('/payments/admin/plans', values);
message.success('Plan created');
}
setModalOpen(false);
form.resetFields();
setEditingPlan(null);
fetchPlans();
} catch {
message.error('Failed to save plan');
}
};
const handleSync = async (id: number) => {
try {
await api.post(`/payments/admin/plans/${id}/sync-stripe`);
message.success('Plan synced to Stripe');
fetchPlans();
} catch {
message.error('Failed to sync to Stripe');
}
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/payments/admin/plans/${id}`);
message.success('Plan deleted');
fetchPlans();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to delete plan';
message.error(msg);
}
};
const columns = [
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'Monthly', dataIndex: 'priceCAD', key: 'price', render: (v: number) => `$${(v / 100).toFixed(2)}/mo` },
{ title: 'Yearly', dataIndex: 'yearlyPriceCAD', key: 'yearly', render: (v: number | null) => v ? `$${(v / 100).toFixed(2)}/yr` : '-' },
{ title: 'Tier', dataIndex: 'tier', key: 'tier' },
{ title: 'Order', dataIndex: 'displayOrder', key: 'order' },
{
title: 'Stripe',
key: 'stripe',
render: (_: unknown, r: SubscriptionPlan) => r.stripeProductId ? <Tag color="green">Synced</Tag> : <Tag>Not synced</Tag>,
},
{
title: 'Active',
dataIndex: 'isActive',
key: 'active',
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? 'Yes' : 'No'}</Tag>,
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, r: SubscriptionPlan) => (
<Space>
<Button size="small" onClick={() => {
setEditingPlan(r);
form.setFieldsValue({ ...r, priceCAD: r.priceCAD / 100, yearlyPriceCAD: r.yearlyPriceCAD ? r.yearlyPriceCAD / 100 : null });
setModalOpen(true);
}}>
Edit
</Button>
<Button size="small" icon={<SyncOutlined />} onClick={() => handleSync(r.id)} title="Sync to Stripe" />
<Popconfirm title="Delete this plan?" onConfirm={() => handleDelete(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Text type="secondary">Create subscription plans and sync them to Stripe</Text>
<Button icon={<PlusOutlined />} onClick={() => { setEditingPlan(null); form.resetFields(); setModalOpen(true); }}>
Add Plan
</Button>
</div>
<Table dataSource={plans} columns={columns} rowKey="id" loading={loading} pagination={false} />
<Modal
title={editingPlan ? 'Edit Plan' : 'Create Plan'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => { setModalOpen(false); setEditingPlan(null); form.resetFields(); }}
>
<Form form={form} layout="vertical" initialValues={{ durationDays: 30, tier: 0, displayOrder: 0, isActive: true }}>
<Form.Item name="name" label="Plan Name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="priceCAD" label="Monthly Price (CAD)" rules={[{ required: true }]}>
<InputNumber min={0} step={0.01} prefix="$" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="yearlyPriceCAD" label="Yearly Price (CAD, optional)">
<InputNumber min={0} step={0.01} prefix="$" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="durationDays" label="Duration (days)" rules={[{ required: true }]}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="tier" label="Tier (0=free, 1=basic, 2=premium...)">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="displayOrder" label="Display Order">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="isActive" label="Active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
);
}
function DonationSettingsTab() {
const [form] = Form.useForm();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const { message } = App.useApp();
useEffect(() => {
api.get('/payments/admin/settings')
.then(({ data }) => {
form.setFieldsValue({
...data,
donationMinimum: data.donationMinimum / 100,
donationSuggestedAmountsStr: (data.donationSuggestedAmounts || []).map((v: number) => (v / 100).toFixed(2)).join(', '),
});
})
.catch(() => message.error('Failed to load settings'))
.finally(() => setLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleSave = async () => {
setSaving(true);
try {
const values = await form.validateFields();
const toSend: Record<string, unknown> = {};
if (values.enableDonations !== undefined) toSend.enableDonations = values.enableDonations;
if (values.donationMinimum) toSend.donationMinimum = Math.round(values.donationMinimum * 100);
if (values.donationPageTitle) toSend.donationPageTitle = values.donationPageTitle;
if (values.donationPageDescription !== undefined) toSend.donationPageDescription = values.donationPageDescription;
if (values.thankYouMessage) toSend.thankYouMessage = values.thankYouMessage;
if (values.donationSuggestedAmountsStr) {
toSend.donationSuggestedAmounts = values.donationSuggestedAmountsStr
.split(',')
.map((s: string) => Math.round(parseFloat(s.trim()) * 100))
.filter((n: number) => !isNaN(n) && n > 0);
}
await api.put('/payments/admin/settings', toSend);
message.success('Donation settings saved');
} catch {
message.error('Failed to save');
} finally {
setSaving(false);
}
};
if (loading) return null;
return (
<Form form={form} layout="vertical" style={{ maxWidth: 600 }}>
<Form.Item name="enableDonations" label="Enable Donations" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="donationMinimum" label="Minimum Donation (CAD)">
<InputNumber min={1} step={0.5} prefix="$" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="donationSuggestedAmountsStr" label="Suggested Amounts (comma-separated, in dollars)">
<Input placeholder="10.00, 25.00, 50.00, 100.00" />
</Form.Item>
<Form.Item name="donationPageTitle" label="Donation Page Title">
<Input />
</Form.Item>
<Form.Item name="donationPageDescription" label="Donation Page Description">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="thankYouMessage" label="Thank You Message">
<Input.TextArea rows={3} />
</Form.Item>
<Button type="primary" onClick={handleSave} loading={saving}>Save Donation Settings</Button>
</Form>
);
}
export default function PaymentSettingsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
useEffect(() => {
setPageHeader({ title: 'Payment Settings' });
return () => setPageHeader(null);
}, [setPageHeader]);
return (
<div>
<Card>
<Tabs
items={[
{ key: 'stripe', label: 'Stripe', children: <StripeSettingsTab /> },
{ key: 'plans', label: 'Plans', children: <PlansTab /> },
{ key: 'donations', label: 'Donations', children: <DonationSettingsTab /> },
]}
/>
</Card>
</div>
);
}

View File

@ -0,0 +1,93 @@
import { useState, useEffect } from 'react';
import { Card, Row, Col, Statistic, Table, Spin, App } from 'antd';
import { DollarOutlined, TeamOutlined, RiseOutlined, HeartOutlined } from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { PaymentDashboardStats, AppOutletContext } from '@/types/api';
export default function PaymentsDashboardPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const [stats, setStats] = useState<PaymentDashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const { message } = App.useApp();
useEffect(() => {
setPageHeader({ title: 'Payments Dashboard' });
return () => setPageHeader(null);
}, [setPageHeader]);
useEffect(() => {
api.get('/payments/admin/dashboard')
.then(({ data }) => setStats(data))
.catch(() => message.error('Failed to load dashboard'))
.finally(() => setLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
const donationColumns = [
{ title: 'Date', dataIndex: 'createdAt', key: 'date', render: (v: string) => new Date(v).toLocaleDateString() },
{ title: 'Amount', dataIndex: 'amountCAD', key: 'amount', render: (v: number) => `$${(v / 100).toFixed(2)}` },
{ title: 'Donor', dataIndex: 'buyerEmail', key: 'donor' },
{ title: 'Status', dataIndex: 'status', key: 'status' },
];
return (
<div>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={6}>
<Card>
<Statistic
title="Active Subscribers"
value={stats?.activeSubscribers || 0}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title="Monthly Recurring Revenue"
value={(stats?.mrr || 0) / 100}
precision={2}
prefix={<RiseOutlined />}
suffix="CAD"
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title="Total Revenue"
value={(stats?.totalRevenue || 0) / 100}
precision={2}
prefix={<DollarOutlined />}
suffix="CAD"
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title="Total Donations"
value={stats?.donations?.totalDonations || 0}
prefix={<HeartOutlined />}
suffix={`($${((stats?.donations?.totalAmount || 0) / 100).toFixed(2)})`}
/>
</Card>
</Col>
</Row>
<Card title="Recent Donations" style={{ marginBottom: 24 }}>
<Table
dataSource={stats?.donations?.recentDonations || []}
columns={donationColumns}
rowKey="id"
pagination={false}
size="small"
/>
</Card>
</div>
);
}

View File

@ -0,0 +1,217 @@
import { useState, useEffect, useCallback } from 'react';
import { Table, Button, Space, App, Modal, Form, Input, InputNumber, Select, Switch, Tag, Popconfirm } from 'antd';
import { PlusOutlined, SyncOutlined, EditOutlined, DeleteOutlined, PictureOutlined } from '@ant-design/icons';
import { useNavigate, useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { Product, PaginationMeta, ProductType, AppOutletContext } from '@/types/api';
export default function ProductsPage() {
const navigate = useNavigate();
const { setPageHeader } = useOutletContext<AppOutletContext>();
const [products, setProducts] = useState<Product[]>([]);
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [form] = Form.useForm();
const { message } = App.useApp();
const fetchProducts = useCallback(async (page = 1) => {
setLoading(true);
try {
const { data } = await api.get('/payments/admin/products', { params: { page, limit: 20 } });
setProducts(data.products);
setPagination(data.pagination);
} catch {
message.error('Failed to load products');
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => { fetchProducts(); }, [fetchProducts]);
useEffect(() => {
setPageHeader({ title: 'Products' });
return () => setPageHeader(null);
}, [setPageHeader]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
// Convert price to cents
values.priceCAD = Math.round(values.priceCAD * 100);
if (editingProduct) {
await api.put(`/payments/admin/products/${editingProduct.id}`, values);
message.success('Product updated');
} else {
await api.post('/payments/admin/products', values);
message.success('Product created');
}
setModalOpen(false);
form.resetFields();
setEditingProduct(null);
fetchProducts(pagination.page);
} catch {
message.error('Failed to save product');
}
};
const handleEdit = (product: Product) => {
setEditingProduct(product);
form.setFieldsValue({
...product,
priceCAD: product.priceCAD / 100,
});
setModalOpen(true);
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/payments/admin/products/${id}`);
message.success('Product deleted');
fetchProducts(pagination.page);
} catch {
message.error('Failed to delete product');
}
};
const handleSyncStripe = async (id: string) => {
try {
await api.post(`/payments/admin/products/${id}/sync-stripe`);
message.success('Synced to Stripe');
fetchProducts(pagination.page);
} catch {
message.error('Failed to sync to Stripe');
}
};
const typeColors: Record<ProductType, string> = {
DIGITAL: 'blue',
EVENT: 'green',
DONATION: 'purple',
};
const columns = [
{ title: 'Title', dataIndex: 'title', key: 'title' },
{ title: 'Slug', dataIndex: 'slug', key: 'slug' },
{
title: 'Type',
dataIndex: 'type',
key: 'type',
render: (t: ProductType) => <Tag color={typeColors[t]}>{t}</Tag>,
},
{
title: 'Price',
dataIndex: 'priceCAD',
key: 'price',
render: (v: number) => `$${(v / 100).toFixed(2)}`,
},
{
title: 'Sales',
key: 'sales',
render: (_: unknown, r: Product) => r.maxPurchases ? `${r.purchaseCount}/${r.maxPurchases}` : r.purchaseCount,
},
{
title: 'Active',
dataIndex: 'isActive',
key: 'active',
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? 'Active' : 'Inactive'}</Tag>,
},
{
title: 'Stripe',
key: 'stripe',
render: (_: unknown, r: Product) => r.stripeProductId
? <Tag color="green">Synced</Tag>
: <Tag>Not synced</Tag>,
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, r: Product) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)} />
<Button size="small" icon={<SyncOutlined />} onClick={() => handleSyncStripe(r.id)} title="Sync to Stripe" />
<Button size="small" icon={<PictureOutlined />} onClick={() => navigate('/app/media/gallery-ads')} title="View Gallery Ad" />
<Popconfirm title="Delete this product?" onConfirm={() => handleDelete(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingProduct(null);
form.resetFields();
setModalOpen(true);
}}
>
Add Product
</Button>
</div>
<Table
dataSource={products}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
onChange: (p) => fetchProducts(p),
}}
/>
<Modal
title={editingProduct ? 'Edit Product' : 'Create Product'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => { setModalOpen(false); setEditingProduct(null); form.resetFields(); }}
width={600}
>
<Form form={form} layout="vertical" initialValues={{ type: 'DIGITAL', isActive: true }}>
<Form.Item name="title" label="Title" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="slug" label="Slug" rules={[{ required: true, pattern: /^[a-z0-9-]+$/, message: 'lowercase, numbers, dashes only' }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="priceCAD" label="Price (CAD)" rules={[{ required: true }]}>
<InputNumber min={0} step={0.01} prefix="$" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="type" label="Type" rules={[{ required: true }]}>
<Select options={[
{ value: 'DIGITAL', label: 'Digital Product' },
{ value: 'EVENT', label: 'Event Ticket' },
{ value: 'DONATION', label: 'Donation' },
]} />
</Form.Item>
<Form.Item name="imageUrl" label="Image URL">
<Input placeholder="https://..." />
</Form.Item>
<Form.Item name="downloadUrl" label="Download URL (for digital products)">
<Input placeholder="File path or URL" />
</Form.Item>
<Form.Item name="maxPurchases" label="Max Purchases (capacity limit)">
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="isActive" label="Active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@ -0,0 +1,195 @@
import { useState, useEffect, useCallback } from 'react';
import { Table, Card, Input, Select, Button, Tag, Space, Typography, App, Popconfirm, Drawer } from 'antd';
import { SearchOutlined, StopOutlined, DownloadOutlined } from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { UserSubscription, PaginationMeta, AppOutletContext } from '@/types/api';
const { Text } = Typography;
export default function SubscribersPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const [subscriptions, setSubscriptions] = useState<UserSubscription[]>([]);
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>();
const [selectedSub, setSelectedSub] = useState<UserSubscription | null>(null);
const { message } = App.useApp();
useEffect(() => {
setPageHeader({ title: 'Subscribers' });
return () => setPageHeader(null);
}, [setPageHeader]);
const fetchData = useCallback(async (page = 1) => {
setLoading(true);
try {
const params: Record<string, string | number> = { page, limit: 20 };
if (search) params.search = search;
if (statusFilter) params.status = statusFilter;
const { data: subData } = await api.get('/payments/admin/subscriptions', { params });
setSubscriptions(subData.subscriptions);
setPagination(subData.pagination);
} catch {
message.error('Failed to load subscribers');
} finally {
setLoading(false);
}
}, [search, statusFilter, message]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleCancel = async (id: number, immediate: boolean) => {
try {
await api.post(`/payments/admin/subscriptions/${id}/cancel`, { immediate });
message.success('Subscription cancelled');
fetchData(pagination.page);
} catch {
message.error('Failed to cancel subscription');
}
};
const handleExport = async () => {
try {
const params: Record<string, string> = {};
if (search) params.search = search;
if (statusFilter) params.status = statusFilter;
const { data } = await api.get('/payments/admin/subscriptions/export', {
params,
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([data]));
const a = document.createElement('a');
a.href = url;
a.download = `subscriptions-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
window.URL.revokeObjectURL(url);
} catch {
message.error('Failed to export subscriptions');
}
};
const statusColors: Record<string, string> = {
active: 'green',
cancelled: 'red',
grace_period: 'orange',
delinquent: 'volcano',
none: 'default',
lifetime: 'blue',
};
const columns = [
{
title: 'User',
key: 'user',
render: (_: unknown, r: UserSubscription) => (
<div>
<div>{r.user?.name || 'Unknown'}</div>
<Text type="secondary" style={{ fontSize: 12 }}>{r.user?.email}</Text>
</div>
),
},
{ title: 'Plan', key: 'plan', render: (_: unknown, r: UserSubscription) => r.plan?.name || `Plan #${r.planId}` },
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (s: string) => <Tag color={statusColors[s] || 'default'}>{s}</Tag>,
},
{
title: 'Started',
dataIndex: 'startDate',
key: 'start',
render: (v: string) => new Date(v).toLocaleDateString(),
},
{
title: 'Renews',
key: 'renews',
render: (_: unknown, r: UserSubscription) => {
if (r.cancelAtPeriodEnd) return <Tag color="orange">Cancelling</Tag>;
return r.currentPeriodEnd ? new Date(r.currentPeriodEnd).toLocaleDateString() : '-';
},
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, r: UserSubscription) => (
<Space>
<Button size="small" onClick={() => setSelectedSub(r)}>Details</Button>
{r.status === 'active' && (
<Popconfirm title="Cancel this subscription?" onConfirm={() => handleCancel(r.id, false)}>
<Button size="small" danger icon={<StopOutlined />}>Cancel</Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
<Card style={{ marginBottom: 16 }}>
<Space wrap>
<Input
placeholder="Search by name or email"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => setSearch(e.target.value)}
onPressEnter={() => fetchData(1)}
style={{ width: 250 }}
/>
<Select
placeholder="Status"
allowClear
value={statusFilter}
onChange={(v) => { setStatusFilter(v); }}
style={{ width: 150 }}
options={[
{ value: 'active', label: 'Active' },
{ value: 'cancelled', label: 'Cancelled' },
{ value: 'grace_period', label: 'Grace Period' },
{ value: 'delinquent', label: 'Delinquent' },
]}
/>
<Button type="primary" onClick={() => fetchData(1)}>Search</Button>
<Button icon={<DownloadOutlined />} onClick={handleExport}>Export CSV</Button>
</Space>
</Card>
<Table
dataSource={subscriptions}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
onChange: (p) => fetchData(p),
}}
/>
<Drawer
title="Subscription Details"
open={!!selectedSub}
onClose={() => setSelectedSub(null)}
width={400}
>
{selectedSub && (
<div>
<p><strong>User:</strong> {selectedSub.user?.name || selectedSub.user?.email}</p>
<p><strong>Plan:</strong> {selectedSub.plan?.name}</p>
<p><strong>Status:</strong> <Tag color={statusColors[selectedSub.status]}>{selectedSub.status}</Tag></p>
<p><strong>Started:</strong> {new Date(selectedSub.startDate).toLocaleString()}</p>
<p><strong>Current Period End:</strong> {selectedSub.currentPeriodEnd ? new Date(selectedSub.currentPeriodEnd).toLocaleString() : '-'}</p>
<p><strong>Cancel at Period End:</strong> {selectedSub.cancelAtPeriodEnd ? 'Yes' : 'No'}</p>
<p><strong>Stripe Subscription ID:</strong> {selectedSub.stripeSubscriptionId || '-'}</p>
<p><strong>Stripe Customer ID:</strong> {selectedSub.stripeCustomerId || '-'}</p>
</div>
)}
</Drawer>
</div>
);
}

View File

@ -29,6 +29,7 @@ import {
} from '@ant-design/icons';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import type {
Campaign,
Representative,
@ -50,6 +51,7 @@ export default function CampaignPage() {
const isMobile = !screens.md;
const { token } = theme.useToken();
const { isAuthenticated } = useAuthStore();
const { settings: siteSettings } = useSettingsStore();
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [loading, setLoading] = useState(true);
@ -74,6 +76,12 @@ export default function CampaignPage() {
const [editBody, setEditBody] = useState('');
const [linkCopied, setLinkCopied] = useState(false);
useEffect(() => {
if (campaign) {
document.title = `${campaign.title} | ${siteSettings?.organizationName || 'Changemaker'}`;
}
}, [campaign, siteSettings?.organizationName]);
useEffect(() => {
fetchCampaign();
}, [slug]); // eslint-disable-line react-hooks/exhaustive-deps
@ -452,10 +460,13 @@ export default function CampaignPage() {
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', width: isMobile ? '100%' : 'auto' }}>
{rep.email && !isSent && (
{rep.email && !isSent && (campaign.allowSmtpEmail || campaign.allowMailtoLink) && (
<>
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 12, width: '100%', marginBottom: 2 }}>
Choose how to send your message:
</Text>
{campaign.allowSmtpEmail && (
<Tooltip title="Send the campaign email directly through our system">
<Tooltip title="We send the email for you right now — no extra steps">
<Button
type="primary"
size="small"
@ -464,18 +475,18 @@ export default function CampaignPage() {
onClick={() => handleSendSmtp(rep)}
style={{ background: '#005a9c' }}
>
Send via System
Send Now
</Button>
</Tooltip>
)}
{campaign.allowMailtoLink && (
<Tooltip title="Opens your default email app with the message pre-filled">
<Tooltip title="Opens Gmail, Outlook, or your default email app with the message ready to send">
<Button
size="small"
icon={<MailOutlined />}
onClick={() => handleMailto(rep)}
>
Open Your Email
Open in My Email App
</Button>
</Tooltip>
)}

View File

@ -36,6 +36,8 @@ import {
EditOutlined,
EnvironmentOutlined,
BankOutlined,
CalendarOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import { useSettingsStore } from '@/stores/settings.store';
@ -73,6 +75,10 @@ export default function CampaignsListPage() {
// Campaign selector drawer (mobile)
const [campaignDrawerOpen, setCampaignDrawerOpen] = useState(false);
useEffect(() => {
document.title = `Campaigns | ${siteSettings?.organizationName || 'Changemaker'}`;
}, [siteSettings?.organizationName]);
useEffect(() => {
fetchCampaigns();
}, []);
@ -112,7 +118,7 @@ export default function CampaignsListPage() {
const navigateToCampaign = (slug: string) => {
const pc = postalCode.replace(/\s/g, '').toUpperCase();
window.location.href = `/campaign/${slug}?postalCode=${pc}`;
navigate(`/campaign/${slug}?postalCode=${pc}`);
};
const navigateToCreate = () => {
@ -264,6 +270,7 @@ export default function CampaignsListPage() {
onChange={(e) => setPostalCode(e.target.value)}
onPressEnter={handleLookup}
size="large"
aria-label="Postal code"
style={{ textTransform: 'uppercase', flex: screens.sm ? 1 : undefined }}
disabled={lookupLoading}
prefix={lookupLoading ? <Spin size="small" /> : undefined}
@ -510,6 +517,40 @@ export default function CampaignsListPage() {
</Row>
)}
{/* Explore More Section */}
{(() => {
const links = [
{ to: '/map', icon: <EnvironmentOutlined />, label: 'Interactive Map', desc: 'Explore locations on the map', show: siteSettings?.enableMap !== false },
{ to: '/shifts', icon: <CalendarOutlined />, label: 'Volunteer Shifts', desc: 'Sign up for upcoming events', show: siteSettings?.enableMap !== false },
{ to: '/gallery', icon: <PlayCircleOutlined />, label: 'Media Gallery', desc: 'Watch videos and media', show: siteSettings?.enableMediaFeatures !== false },
].filter(l => l.show);
if (links.length === 0) return null;
return (
<div style={{ marginTop: 48, textAlign: 'center' }}>
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }}>
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 13 }}>Explore More</Text>
</Divider>
<Row gutter={[16, 16]} justify="center" style={{ marginTop: 16 }}>
{links.map(link => (
<Col xs={24} sm={8} key={link.to}>
<Link to={link.to} style={{ textDecoration: 'none' }}>
<Card
hoverable
size="small"
style={{ background: colorBgContainer, border: '1px solid rgba(255,255,255,0.08)', textAlign: 'center' }}
>
<div style={{ fontSize: 24, marginBottom: 8, color: colorPrimary }}>{link.icon}</div>
<Text strong style={{ display: 'block' }}>{link.label}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{link.desc}</Text>
</Card>
</Link>
</Col>
))}
</Row>
</div>
);
})()}
<AuthModal
open={authModalOpen}
onCancel={() => setAuthModalOpen(false)}
@ -713,7 +754,7 @@ function RepCard({
style={{ background: '#16a34a', border: 'none', color: '#fff', flex: isMobile ? 1 : undefined }}
onClick={() => {
const phone = rep.offices?.find(o => o.tel)?.tel;
if (phone) window.open(`tel:${phone.replace(/\s/g, '')}`, '_blank');
if (phone) window.location.href = `tel:${phone.replace(/\s/g, '')}`;
}}
>
Call
@ -723,7 +764,7 @@ function RepCard({
<Button
size={isMobile ? 'large' : 'middle'}
icon={<GlobalOutlined />}
onClick={() => window.open(rep.url!, '_blank')}
onClick={() => window.open(rep.url!, '_blank', 'noopener,noreferrer')}
style={{ flex: isMobile ? 1 : undefined }}
>
Profile

View File

@ -0,0 +1,176 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Card, Button, Input, InputNumber, Typography, Form, Checkbox, Space, Spin, App } from 'antd';
import { HeartOutlined } from '@ant-design/icons';
import axios from 'axios';
import { useSettingsStore } from '@/stores/settings.store';
const { Title, Paragraph, Text } = Typography;
interface PaymentConfig {
enableDonations: boolean;
donationSuggestedAmounts: number[];
donationMinimum: number;
donationPageTitle: string;
donationPageDescription: string | null;
}
export default function DonatePage() {
const [config, setConfig] = useState<PaymentConfig | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
const [customAmount, setCustomAmount] = useState<number | null>(null);
const [form] = Form.useForm();
const { message } = App.useApp();
const { settings: siteSettings } = useSettingsStore();
const [searchParams] = useSearchParams();
useEffect(() => {
document.title = `Donate | ${siteSettings?.organizationName || 'Changemaker'}`;
}, [siteSettings?.organizationName]);
useEffect(() => {
axios.get('/api/payments/config')
.then(({ data }) => {
setConfig(data);
// Check for ?amount= query param (from set-amount links in docs/landing pages)
const amountParam = searchParams.get('amount');
if (amountParam) {
const parsed = parseInt(amountParam, 10);
if (!isNaN(parsed) && parsed > 0) {
// If the amount matches a suggested amount, select it; otherwise use custom
const amounts = data.donationSuggestedAmounts || [];
if (amounts.includes(parsed)) {
setSelectedAmount(parsed);
} else {
setSelectedAmount(null);
setCustomAmount(parsed / 100);
}
return;
}
}
const amounts = data.donationSuggestedAmounts || [];
if (amounts.length > 0) setSelectedAmount(amounts[0]);
})
.catch(() => message.error('Failed to load donation settings'))
.finally(() => setLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleDonate = async () => {
const values = await form.validateFields();
const amountCents = selectedAmount || (customAmount ? Math.round(customAmount * 100) : 0);
if (!amountCents || amountCents < (config?.donationMinimum || 500)) {
message.error(`Minimum donation is $${((config?.donationMinimum || 500) / 100).toFixed(2)}`);
return;
}
setSubmitting(true);
try {
const { data } = await axios.post('/api/payments/donate', {
amountCents,
email: values.email,
name: values.name || undefined,
message: values.message || undefined,
isAnonymous: values.isAnonymous || false,
});
if (data.url) {
window.location.href = data.url;
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to start donation';
message.error(msg);
} finally {
setSubmitting(false);
}
};
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
if (!config?.enableDonations) {
return (
<Card style={{ textAlign: 'center', padding: 40 }}>
<Title level={3}>Donations are currently disabled</Title>
</Card>
);
}
const suggestedAmounts = config.donationSuggestedAmounts || [];
return (
<div style={{ maxWidth: 500, margin: '0 auto' }}>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<HeartOutlined style={{ fontSize: 48, color: '#eb2f96', marginBottom: 16 }} />
<Title level={2}>{config.donationPageTitle}</Title>
{config.donationPageDescription && (
<Paragraph style={{ fontSize: 16, opacity: 0.8 }}>{config.donationPageDescription}</Paragraph>
)}
</div>
<Card>
<div style={{ marginBottom: 24 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>Select an amount</Text>
<Space wrap>
{suggestedAmounts.map((amt) => (
<Button
key={amt}
type={selectedAmount === amt ? 'primary' : 'default'}
size="large"
onClick={() => { setSelectedAmount(amt); setCustomAmount(null); }}
>
${(amt / 100).toFixed(0)}
</Button>
))}
<Button
type={!selectedAmount ? 'primary' : 'default'}
size="large"
onClick={() => setSelectedAmount(null)}
>
Custom
</Button>
</Space>
</div>
{!selectedAmount && (
<div style={{ marginBottom: 24 }}>
<InputNumber
size="large"
prefix="$"
min={(config.donationMinimum || 500) / 100}
step={1}
value={customAmount}
onChange={(v) => setCustomAmount(v)}
style={{ width: '100%' }}
placeholder={`Minimum $${((config.donationMinimum || 500) / 100).toFixed(2)}`}
/>
</div>
)}
<Form form={form} layout="vertical">
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input size="large" placeholder="your@email.com" />
</Form.Item>
<Form.Item name="name" label="Name (optional)">
<Input size="large" />
</Form.Item>
<Form.Item name="message" label="Message (optional)">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="isAnonymous" valuePropName="checked">
<Checkbox>Make my donation anonymous</Checkbox>
</Form.Item>
</Form>
<Button
type="primary"
size="large"
block
icon={<HeartOutlined />}
onClick={handleDonate}
loading={submitting}
>
Donate {selectedAmount ? `$${(selectedAmount / 100).toFixed(0)}` : customAmount ? `$${customAmount.toFixed(2)}` : ''}
</Button>
</Card>
</div>
);
}

View File

@ -6,6 +6,9 @@ import axios from 'axios';
import type { LandingPage as LandingPageType } from '@/types/api';
import { VideoPlayer } from '@/components/media/VideoPlayer';
import { AdvancedVideoPlayer } from '@/components/media/AdvancedVideoPlayer';
import { DonationWidget } from '@/components/payments/DonationWidget';
import { PricingWidget } from '@/components/payments/PricingWidget';
import { ProductWidget } from '@/components/payments/ProductWidget';
export default function PublicLandingPage() {
const { slug } = useParams<{ slug: string }>();
@ -14,6 +17,7 @@ export default function PublicLandingPage() {
const [notFound, setNotFound] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const videoRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
const paymentRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
useEffect(() => {
const fetchPage = async () => {
@ -154,20 +158,71 @@ export default function PublicLandingPage() {
}
};
// Hydrate payment blocks (donate, pricing, product)
const hydratePaymentBlocks = () => {
const paymentBlocks = contentRef.current?.querySelectorAll('.payment-block');
if (!paymentBlocks) return;
// Clean up previous roots
paymentRootsRef.current.forEach((root) => {
try { root.unmount(); } catch (err) { console.error('Failed to unmount payment root:', err); }
});
paymentRootsRef.current = [];
paymentBlocks.forEach((blockEl) => {
const paymentType = blockEl.getAttribute('data-payment-type');
if (!paymentType) return;
// Create a container to replace block inner content
const container = document.createElement('div');
blockEl.innerHTML = '';
blockEl.appendChild(container);
try {
const root = createRoot(container);
paymentRootsRef.current.push(root);
switch (paymentType) {
case 'donate': {
const buttonText = blockEl.getAttribute('data-button-text') || 'Donate Now';
const showAmounts = blockEl.getAttribute('data-show-amounts') !== 'false';
root.render(<DonationWidget buttonText={buttonText} showAmounts={showAmounts} />);
break;
}
case 'pricing': {
const showYearly = blockEl.getAttribute('data-show-yearly') !== 'false';
root.render(<PricingWidget showYearlyToggle={showYearly} />);
break;
}
case 'product': {
const productSlug = blockEl.getAttribute('data-product-slug') || '';
const buttonText = blockEl.getAttribute('data-button-text') || 'Buy Now';
root.render(<ProductWidget productSlug={productSlug} buttonText={buttonText} />);
break;
}
}
} catch (err) {
console.error('Failed to render payment widget:', err);
}
});
};
// Hydrate after DOM is ready
setTimeout(hydrateVideoBlocks, 100);
setTimeout(hydrateVideoCards, 200);
setTimeout(hydratePaymentBlocks, 150);
// Cleanup on unmount
return () => {
videoRootsRef.current.forEach((root) => {
try {
root.unmount();
} catch (err) {
console.error('Failed to unmount video root on cleanup:', err);
}
try { root.unmount(); } catch (err) { console.error('Failed to unmount video root on cleanup:', err); }
});
videoRootsRef.current = [];
paymentRootsRef.current.forEach((root) => {
try { root.unmount(); } catch (err) { console.error('Failed to unmount payment root on cleanup:', err); }
});
paymentRootsRef.current = [];
};
}, [page]);

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { ConfigProvider, Spin, Typography, theme, Button, Tooltip, message } from 'antd';
import { Link } from 'react-router-dom';
import { ArrowLeftOutlined, AimOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
import { ConfigProvider, Spin, Typography, theme, Button, Tooltip, message, Result } from 'antd';
import { Link, useNavigate } from 'react-router-dom';
import { ArrowLeftOutlined, AimOutlined, FullscreenOutlined, FullscreenExitOutlined, CalendarOutlined, SendOutlined } from '@ant-design/icons';
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents } from 'react-leaflet';
import type { Map as LeafletMap } from 'leaflet';
import axios from 'axios';
@ -14,8 +14,7 @@ declare module 'leaflet' {
}
}
import { useSettingsStore } from '@/stores/settings.store';
import type { MapSettings, Location, SupportLevel, PublicCut } from '@/types/api';
import { SUPPORT_LEVEL_LABELS } from '@/types/api';
import type { MapSettings, Location, PublicCut } from '@/types/api';
import { groupLocations, getMarkerColor } from '@/components/map/mapUtils';
import MapLegend from '@/components/map/MapLegend';
import CutOverlays from '@/components/map/CutOverlays';
@ -95,6 +94,7 @@ export default function MapPage() {
const [settings, setSettings] = useState<MapSettings | null>(null);
const [locations, setLocations] = useState<Location[]>([]);
const [loading, setLoading] = useState(true);
const [mapDisabled, setMapDisabled] = useState(false);
const [loadingLocations, setLoadingLocations] = useState(false);
const [cuts, setCuts] = useState<PublicCut[]>([]);
const [visibleCutIds, setVisibleCutIds] = useState<Set<string>>(new Set());
@ -105,6 +105,11 @@ export default function MapPage() {
const fetchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const abortControllerRef = useRef<AbortController | null>(null);
const { settings: siteSettings } = useSettingsStore();
const navigate = useNavigate();
useEffect(() => {
document.title = `Map | ${siteSettings?.organizationName || 'Changemaker'}`;
}, [siteSettings?.organizationName]);
const fetchLocations = useCallback(async (b: BoundsQuery) => {
if (abortControllerRef.current) {
@ -166,6 +171,11 @@ export default function MapPage() {
axios.get<PublicCut[]>('/api/map/cuts/public'),
]);
setSettings(settingsRes.data);
if (settingsRes.data.publicMapEnabled === false) {
setMapDisabled(true);
setLoading(false);
return;
}
setCuts(cutsRes.data);
setVisibleCutIds(new Set(cutsRes.data.map((c) => c.id)));
} catch {
@ -241,6 +251,56 @@ export default function MapPage() {
});
}, []);
if (mapDisabled) {
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: siteSettings?.publicColorPrimary ?? '#3498db',
colorBgBase: siteSettings?.publicColorBgBase ?? '#0d1b2a',
colorBgContainer: siteSettings?.publicColorBgContainer ?? '#1b2838',
},
}}
>
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: siteSettings?.publicColorBgBase ?? '#0d1b2a' }}>
<div
style={{
height: HEADER_HEIGHT,
background: siteSettings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
flexShrink: 0,
}}
>
<Link to="/campaigns" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 8 }}>
<ArrowLeftOutlined style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14 }} />
<Typography.Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13 }}>Back</Typography.Text>
</Link>
<Typography.Text strong style={{ fontSize: 16, color: '#fff' }}>
{siteSettings?.organizationName ?? 'Changemaker Lite'}
</Typography.Text>
<div />
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Result
status="info"
title="Map Unavailable"
subTitle="The public map is currently not available."
extra={
<Button type="primary" onClick={() => navigate('/campaigns')}>
View Campaigns
</Button>
}
/>
</div>
</div>
</ConfigProvider>
);
}
return (
<ConfigProvider
theme={{
@ -273,13 +333,22 @@ export default function MapPage() {
<Link to="/campaigns" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 8 }}>
<ArrowLeftOutlined style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14 }} />
<Typography.Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13 }}>
Back to Campaigns
Back
</Typography.Text>
</Link>
<Typography.Text strong style={{ fontSize: 16, color: '#fff' }}>
{siteSettings?.organizationName ?? 'Changemaker Lite'}
</Typography.Text>
<div style={{ width: 140 }} /> {/* Spacer for centering */}
<div style={{ display: 'flex', gap: 8 }}>
<Link to="/shifts" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
<CalendarOutlined style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13 }} />
<Typography.Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13 }}>Shifts</Typography.Text>
</Link>
<Link to="/campaigns" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
<SendOutlined style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13 }} />
<Typography.Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13 }}>Campaigns</Typography.Text>
</Link>
</div>
</div>
{/* Full-viewport map */}
@ -380,89 +449,22 @@ export default function MapPage() {
<Popup>
<div style={{ minWidth: 180, maxWidth: 280 }}>
{group.isMultiUnit ? (
// Multi-unit building display
<>
<div style={{ marginBottom: 8, paddingBottom: 8, borderBottom: '2px solid #a02c8d' }}>
<div style={{ fontWeight: 600, fontSize: 14, color: '#a02c8d' }}>
🏢 {group.location.address || 'Multi-Unit Building'}
</div>
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
{group.location.addresses.length} units
</div>
<div style={{ fontWeight: 600, fontSize: 14, color: '#a02c8d' }}>
{group.location.address || 'Multi-Unit Building'}
</div>
<div style={{ fontSize: 12, color: '#666', marginTop: 2 }}>
{group.location.addresses.length} units at this location
</div>
{group.location.addresses
.sort((a, b) => {
// Sort by unit number
const aUnit = a.unitNumber || '';
const bUnit = b.unitNumber || '';
return aUnit.localeCompare(bUnit, undefined, { numeric: true });
})
.map((addr, i) => (
<div
key={addr.id}
style={{
marginBottom: i < group.location.addresses.length - 1 ? 8 : 0,
paddingBottom: i < group.location.addresses.length - 1 ? 8 : 0,
borderBottom: i < group.location.addresses.length - 1 ? '1px solid #eee' : 'none',
}}
>
{addr.unitNumber && (
<div style={{ fontSize: 12, fontWeight: 600, color: '#555' }}>
Unit {addr.unitNumber}
</div>
)}
{addr.supportLevel && (
<div style={{ fontSize: 12, marginTop: 2 }}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: getMarkerColor(addr.supportLevel as SupportLevel),
marginRight: 4,
}}
/>
{SUPPORT_LEVEL_LABELS[addr.supportLevel as SupportLevel]}
</div>
)}
{addr.sign && (
<div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>
Sign{addr.signSize ? ` (${addr.signSize})` : ''}
</div>
)}
</div>
))}
</>
) : (
// Single unit display
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>
{group.location.address || 'Unknown address'}
{group.location.address || 'Location'}
</div>
{group.location.addresses[0]?.unitNumber && (
<div style={{ fontSize: 12, color: '#666' }}>Unit {group.location.addresses[0].unitNumber}</div>
)}
{group.location.addresses[0]?.supportLevel && (
<div style={{ fontSize: 12 }}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: getMarkerColor(group.location.addresses[0].supportLevel as SupportLevel),
marginRight: 4,
}}
/>
{SUPPORT_LEVEL_LABELS[group.location.addresses[0].supportLevel as SupportLevel]}
</div>
)}
{group.location.addresses[0]?.sign && (
<div style={{ fontSize: 11, color: '#888' }}>
Sign{group.location.addresses[0].signSize ? ` (${group.location.addresses[0].signSize})` : ''}
</div>
)}
</div>
)}
</div>

View File

@ -5,12 +5,16 @@ import {
Pagination,
Grid,
} from 'antd';
import axios from 'axios';
import PublicVideoCard from '@/components/media/PublicVideoCard';
import ExpandedVideoCard from '@/components/media/ExpandedVideoCard';
import GalleryAdCard from '@/components/media/GalleryAdCard';
import FeaturedPlaylistCarousel from '@/components/media/FeaturedPlaylistCarousel';
import { mediaPublicApi } from '@/lib/media-public-api';
import { useParams, useSearchParams } from 'react-router-dom';
import { ExpandedVideoProvider, useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { mergeAdsIntoGrid, type GridItem } from '@/utils/galleryAdMerge';
import type { GalleryAd } from '@/types/gallery-ads';
const { useBreakpoint } = Grid;
@ -49,6 +53,7 @@ function MediaGalleryContent() {
const sort = (searchParams.get('sort') as 'recent' | 'popular' | 'most_viewed') || 'recent';
const [videos, setVideos] = useState<Video[]>([]);
const [ads, setAds] = useState<GalleryAd[]>([]);
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState<PaginationInfo>({
total: 0,
@ -89,6 +94,18 @@ function MediaGalleryContent() {
}
};
// Fetch ads (from Express API, non-critical)
const fetchAds = async () => {
try {
const { data } = await axios.get<GalleryAd[]>('/api/gallery-ads', {
params: { page: currentPage },
});
setAds(data);
} catch {
// Silent fail — ads are supplementary
}
};
// Fetch on filter changes (search/sort come from URL params via MediaBottomNav)
useEffect(() => {
setCurrentPage(1);
@ -96,6 +113,7 @@ function MediaGalleryContent() {
useEffect(() => {
fetchVideos();
fetchAds();
}, [urlCategory, search, sort, currentPage]);
// Handle URL ?expanded=123 parameter on initial load only
@ -119,6 +137,9 @@ function MediaGalleryContent() {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// Merge ads into video grid
const gridItems = mergeAdsIntoGrid(videos, ads);
return (
<div>
{/* Featured Playlists Carousel — only on main gallery page */}
@ -143,7 +164,7 @@ function MediaGalleryContent() {
/>
)}
{/* Video Grid */}
{/* Video Grid (with ads interleaved) */}
{!loading && videos.length > 0 && (
<>
<div
@ -156,16 +177,18 @@ function MediaGalleryContent() {
gap: 16,
}}
>
{videos.map((video) =>
expandedState.videoId === video.id ? (
<ExpandedVideoCard key={`expanded-${video.id}`} video={video} />
{gridItems.map((item: GridItem<Video>) =>
item.type === 'ad' ? (
<GalleryAdCard key={`ad-${item.data.id}`} ad={item.data} />
) : expandedState.videoId === item.data.id ? (
<ExpandedVideoCard key={`expanded-${item.data.id}`} video={item.data} />
) : (
<PublicVideoCard key={video.id} video={video} />
<PublicVideoCard key={item.data.id} video={item.data} />
)
)}
</div>
{/* Pagination */}
{/* Pagination — still based on video count only */}
{pagination.total > pagination.limit && (
<div style={{ marginTop: 32, textAlign: 'center' }}>
<Pagination

View File

@ -63,6 +63,12 @@ export default function MediaViewerPage() {
const videoId = parseInt(id || '0', 10);
// Fetch video details
useEffect(() => {
if (video) {
document.title = `${video.title || video.filename} | Media Gallery`;
}
}, [video]);
useEffect(() => {
const fetchVideo = async () => {
try {
@ -216,8 +222,7 @@ export default function MediaViewerPage() {
return null;
}
// Extract title from filename
const title = video.filename.replace(/\.[^/.]+$/, '');
const title = video.title || video.filename.replace(/\.[^/.]+$/, '');
return (
<div>
@ -256,6 +261,7 @@ export default function MediaViewerPage() {
onClick={handleUpvote}
loading={upvoting}
size="large"
aria-label={upvoted ? 'Remove upvote' : 'Upvote this video'}
>
{formatCount(video.upvoteCount)}
</Button>

View File

@ -0,0 +1,36 @@
import { Typography, Card, Button, Result } from 'antd';
import { CheckCircleOutlined } from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router-dom';
const { Paragraph } = Typography;
export default function PaymentSuccessPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const sessionId = searchParams.get('session_id');
return (
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<Card>
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
title="Payment Successful!"
subTitle="Thank you for your support. Your transaction has been processed."
extra={[
<Button type="primary" key="gallery" onClick={() => navigate('/gallery')}>
Browse Content
</Button>,
<Button key="home" onClick={() => navigate('/campaigns')}>
Back to Home
</Button>,
]}
/>
{sessionId && (
<Paragraph type="secondary" style={{ textAlign: 'center', fontSize: 12 }}>
Reference: {sessionId.slice(0, 20)}...
</Paragraph>
)}
</Card>
</div>
);
}

View File

@ -0,0 +1,157 @@
import { useState, useEffect } from 'react';
import { Card, Button, Row, Col, Typography, Tag, Switch, Space, Spin, App } from 'antd';
import { CheckOutlined, CrownOutlined } from '@ant-design/icons';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import type { SubscriptionPlan } from '@/types/api';
import { api } from '@/lib/api';
const { Title, Text, Paragraph } = Typography;
export default function PricingPage() {
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [loading, setLoading] = useState(true);
const [yearly, setYearly] = useState(false);
const [mySubStatus, setMySubStatus] = useState<string>('none');
const [myPlanId, setMyPlanId] = useState<number | null>(null);
const [subscribingPlanId, setSubscribingPlanId] = useState<number | null>(null);
const { isAuthenticated } = useAuthStore();
const { message } = App.useApp();
const { settings: siteSettings } = useSettingsStore();
useEffect(() => {
document.title = `Pricing | ${siteSettings?.organizationName || 'Changemaker'}`;
}, [siteSettings?.organizationName]);
useEffect(() => {
axios.get('/api/payments/plans')
.then(({ data }) => setPlans(data))
.catch(() => message.error('Failed to load plans'))
.finally(() => setLoading(false));
if (isAuthenticated) {
api.get('/payments/my-subscription')
.then(({ data }) => {
setMySubStatus(data.status || 'none');
setMyPlanId(data.planId || null);
})
.catch(() => {});
}
}, [isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSubscribe = async (planId: number) => {
if (!isAuthenticated) {
message.info('Please sign in to subscribe');
return;
}
setSubscribingPlanId(planId);
try {
const { data } = await api.post('/payments/subscribe', {
planId,
frequency: yearly ? 'yearly' : 'monthly',
});
if (data.url) {
window.location.href = data.url;
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to start checkout';
message.error(msg);
} finally {
setSubscribingPlanId(null);
}
};
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
return (
<div style={{ textAlign: 'center' }}>
<Title level={2}>Choose Your Plan</Title>
<Paragraph style={{ fontSize: 16, opacity: 0.8 }}>
Get access to exclusive content and features
</Paragraph>
<Space style={{ marginBottom: 32 }}>
<Text>Monthly</Text>
<Switch checked={yearly} onChange={setYearly} />
<Text>Yearly <Tag color="green">Save up to 20%</Tag></Text>
</Space>
<Row gutter={[24, 24]} justify="center">
{/* Free tier */}
<Col xs={24} sm={12} md={8}>
<Card
style={{ height: '100%', textAlign: 'center' }}
hoverable
>
<Title level={3}>Free</Title>
<div style={{ margin: '16px 0' }}>
<Text style={{ fontSize: 36, fontWeight: 700 }}>$0</Text>
<Text type="secondary">/forever</Text>
</div>
<div style={{ textAlign: 'left', padding: '0 16px' }}>
<p><CheckOutlined style={{ color: '#52c41a', marginRight: 8 }} />Access public content</p>
<p><CheckOutlined style={{ color: '#52c41a', marginRight: 8 }} />Browse campaigns</p>
<p><CheckOutlined style={{ color: '#52c41a', marginRight: 8 }} />View public map</p>
</div>
{mySubStatus === 'none' && (
<Tag color="blue" style={{ marginTop: 16 }}>Current Plan</Tag>
)}
</Card>
</Col>
{plans.map((plan) => {
const price = yearly && plan.yearlyPriceCAD ? plan.yearlyPriceCAD : plan.priceCAD;
const period = yearly && plan.yearlyPriceCAD ? '/year' : '/month';
const isCurrent = mySubStatus === 'active' && myPlanId === plan.id;
const features = (plan.features || []) as string[];
return (
<Col xs={24} sm={12} md={8} key={plan.id}>
<Card
style={{
height: '100%',
textAlign: 'center',
border: plan.tier >= 2 ? '2px solid #722ed1' : undefined,
}}
hoverable
>
{plan.tier >= 2 && (
<Tag color="purple" style={{ position: 'absolute', top: 12, right: 12 }}>
<CrownOutlined /> Popular
</Tag>
)}
<Title level={3}>{plan.name}</Title>
<div style={{ margin: '16px 0' }}>
<Text style={{ fontSize: 36, fontWeight: 700 }}>${(price / 100).toFixed(2)}</Text>
<Text type="secondary">{period}</Text>
</div>
{plan.description && (
<Paragraph type="secondary" style={{ marginBottom: 16 }}>{plan.description}</Paragraph>
)}
<div style={{ textAlign: 'left', padding: '0 16px' }}>
{features.map((f, i) => (
<p key={i}><CheckOutlined style={{ color: '#52c41a', marginRight: 8 }} />{f}</p>
))}
</div>
{isCurrent ? (
<Tag color="green" style={{ marginTop: 16 }}>Current Plan</Tag>
) : (
<Button
type="primary"
size="large"
style={{ marginTop: 16, width: '100%' }}
onClick={() => handleSubscribe(plan.id)}
loading={subscribingPlanId === plan.id}
>
{isAuthenticated ? 'Subscribe' : 'Sign In to Subscribe'}
</Button>
)}
</Card>
</Col>
);
})}
</Row>
</div>
);
}

View File

@ -43,6 +43,7 @@ import {
RESPONSE_TYPE_LABELS,
} from '@/types/api';
import dayjs from 'dayjs';
import { useSettingsStore } from '@/stores/settings.store';
const { Title, Text, Paragraph } = Typography;
@ -53,6 +54,7 @@ export default function ResponseWallPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const { settings: siteSettings } = useSettingsStore();
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [responses, setResponses] = useState<RepresentativeResponse[]>([]);
@ -66,8 +68,15 @@ export default function ResponseWallPage() {
const [submitModalOpen, setSubmitModalOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [upvotedIds, setUpvotedIds] = useState<Set<string>>(new Set());
const [upvotingId, setUpvotingId] = useState<string | null>(null);
const [form] = Form.useForm();
useEffect(() => {
if (campaign) {
document.title = `Response Wall - ${campaign.title} | ${siteSettings?.organizationName || 'Changemaker'}`;
}
}, [campaign, siteSettings?.organizationName]);
useEffect(() => {
fetchCampaign();
fetchStats();
@ -112,6 +121,7 @@ export default function ResponseWallPage() {
}, [fetchResponses, slug]);
const handleUpvote = async (responseId: string) => {
setUpvotingId(responseId);
if (upvotedIds.has(responseId)) {
// Remove upvote
try {
@ -128,6 +138,8 @@ export default function ResponseWallPage() {
);
} catch {
// Ignore
} finally {
setUpvotingId(null);
}
} else {
try {
@ -145,6 +157,8 @@ export default function ResponseWallPage() {
}
} catch {
// Ignore
} finally {
setUpvotingId(null);
}
}
};
@ -207,6 +221,10 @@ export default function ResponseWallPage() {
{campaign.title}
</Title>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 15 }}>Community Response Wall</Text>
<br />
<Text style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13 }}>
See how elected representatives have responded to this campaign and share responses you've received.
</Text>
</div>
</Link>
)}
@ -243,6 +261,7 @@ export default function ResponseWallPage() {
value={sort}
onChange={(v) => { setSort(v); setPage(1); }}
style={{ width: '100%' }}
aria-label="Sort responses"
options={[
{ value: 'recent', label: 'Most Recent' },
{ value: 'upvotes', label: 'Most Upvotes' },
@ -257,6 +276,7 @@ export default function ResponseWallPage() {
value={levelFilter}
onChange={(v) => { setLevelFilter(v); setPage(1); }}
style={{ width: '100%' }}
aria-label="Filter by government level"
options={Object.entries(GOVERNMENT_LEVEL_LABELS).map(([value, label]) => ({
value,
label,
@ -328,6 +348,7 @@ export default function ResponseWallPage() {
size="small"
icon={upvotedIds.has(response.id) ? <LikeFilled /> : <LikeOutlined />}
onClick={() => handleUpvote(response.id)}
loading={upvotingId === response.id}
aria-label={upvotedIds.has(response.id) ? `Remove upvote (${response.upvoteCount} total)` : `Upvote response (${response.upvoteCount} total)`}
>
{response.upvoteCount}
@ -385,7 +406,7 @@ export default function ResponseWallPage() {
footer={null}
width={isMobile ? '100%' : 560}
style={{ maxWidth: '95vw' }}
destroyOnClose
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item

View File

@ -24,6 +24,8 @@ import {
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import { Link } from 'react-router-dom';
import { useSettingsStore } from '@/stores/settings.store';
const { Title, Text, Paragraph } = Typography;
@ -46,6 +48,7 @@ export default function PublicShiftsPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const { settings: siteSettings } = useSettingsStore();
const [shifts, setShifts] = useState<PublicShift[]>([]);
const [loading, setLoading] = useState(true);
@ -55,6 +58,10 @@ export default function PublicShiftsPage() {
const [successShift, setSuccessShift] = useState<PublicShift | null>(null);
const [form] = Form.useForm();
useEffect(() => {
document.title = `Volunteer Shifts | ${siteSettings?.organizationName || 'Changemaker'}`;
}, [siteSettings?.organizationName]);
useEffect(() => {
fetchShifts();
}, []);
@ -131,8 +138,9 @@ export default function PublicShiftsPage() {
) : (
<Row gutter={[16, 16]}>
{shifts.map((shift) => {
const isCancelled = shift.status === 'CANCELLED';
const spotsLeft = shift.maxVolunteers - shift.currentVolunteers;
const isFull = shift.status === 'FULL' || spotsLeft <= 0;
const isFull = !isCancelled && (shift.status === 'FULL' || spotsLeft <= 0);
const pct = shift.maxVolunteers > 0
? Math.round((shift.currentVolunteers / shift.maxVolunteers) * 100)
: 0;
@ -140,10 +148,10 @@ export default function PublicShiftsPage() {
return (
<Col key={shift.id} xs={24} sm={12} lg={8}>
<Card
hoverable={!isFull}
hoverable={!isFull && !isCancelled}
style={{
height: '100%',
opacity: isFull ? 0.7 : 1,
opacity: isFull || isCancelled ? 0.7 : 1,
}}
styles={{
body: {
@ -209,10 +217,10 @@ export default function PublicShiftsPage() {
type="primary"
block
size="large"
disabled={isFull}
disabled={isFull || isCancelled}
onClick={() => openSignup(shift)}
>
{isFull ? 'Shift Full' : 'Sign Up'}
{isCancelled ? 'Cancelled' : isFull ? 'Shift Full' : 'Sign Up'}
</Button>
</Card>
</Col>
@ -333,6 +341,11 @@ export default function PublicShiftsPage() {
<Text>{successShift.location}</Text>
</div>
)}
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
<Link to="/login" style={{ color: 'inherit', textDecoration: 'underline' }}>Create an account</Link> to access GPS-guided canvassing and track your volunteer activity.
</Text>
</div>
</div>
}
/>

View File

@ -0,0 +1,134 @@
import { useState, useEffect } from 'react';
import { Card, Row, Col, Button, Typography, Tag, Spin, Select, Space, App } from 'antd';
import { ShoppingCartOutlined } from '@ant-design/icons';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import type { Product, ProductType } from '@/types/api';
const { Title, Text, Paragraph } = Typography;
export default function ShopPage() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [typeFilter, setTypeFilter] = useState<string>();
const { user, isAuthenticated } = useAuthStore();
const { message } = App.useApp();
const { settings: siteSettings } = useSettingsStore();
useEffect(() => {
document.title = `Shop | ${siteSettings?.organizationName || 'Changemaker'}`;
}, [siteSettings?.organizationName]);
useEffect(() => {
const params: Record<string, string> = {};
if (typeFilter) params.type = typeFilter;
axios.get('/api/payments/products', { params })
.then(({ data }) => setProducts(data))
.catch(() => message.error('Failed to load products'))
.finally(() => setLoading(false));
}, [typeFilter]); // eslint-disable-line react-hooks/exhaustive-deps
const handlePurchase = async (product: Product) => {
const email = isAuthenticated && user ? user.email : prompt('Enter your email to purchase:');
if (!email) return;
try {
const { data } = await axios.post('/api/payments/purchase', {
productId: product.id,
buyerEmail: email,
buyerName: user?.name || undefined,
});
if (data.url) {
window.location.href = data.url;
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to start checkout';
message.error(msg);
}
};
const typeColors: Record<ProductType, string> = {
DIGITAL: 'blue',
EVENT: 'green',
DONATION: 'purple',
};
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
return (
<div>
<Title level={2} style={{ textAlign: 'center' }}>Shop</Title>
<Paragraph style={{ textAlign: 'center', fontSize: 16, opacity: 0.8 }}>
Reports, toolkits, event tickets, and more
</Paragraph>
<Space style={{ marginBottom: 24 }} wrap>
<Select
placeholder="Filter by type"
allowClear
value={typeFilter}
onChange={setTypeFilter}
style={{ width: 200 }}
options={[
{ value: 'DIGITAL', label: 'Digital Products' },
{ value: 'EVENT', label: 'Events' },
]}
/>
</Space>
{products.length === 0 ? (
<Card style={{ textAlign: 'center', padding: 40 }}>
<Title level={4}>No products available yet</Title>
<Text type="secondary">Check back soon!</Text>
</Card>
) : (
<Row gutter={[16, 16]}>
{products.map((product) => (
<Col xs={24} sm={12} md={8} key={product.id}>
<Card
hoverable
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
cover={product.imageUrl ? (
<img
src={product.imageUrl}
alt={product.title}
style={{ height: 200, objectFit: 'cover' }}
/>
) : undefined}
>
<Tag color={typeColors[product.type]} style={{ marginBottom: 8 }}>{product.type}</Tag>
<Title level={4} style={{ margin: 0 }}>{product.title}</Title>
{product.description && (
<Paragraph
type="secondary"
ellipsis={{ rows: 2 }}
style={{ marginTop: 8 }}
>
{product.description}
</Paragraph>
)}
<div style={{ marginTop: 'auto', paddingTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong style={{ fontSize: 20 }}>${(product.priceCAD / 100).toFixed(2)}</Text>
{product.maxPurchases && product.purchaseCount >= product.maxPurchases ? (
<Tag color="red">Sold Out</Tag>
) : (
<Button
type="primary"
icon={<ShoppingCartOutlined />}
onClick={() => handlePurchase(product)}
>
Buy
</Button>
)}
</div>
</div>
</Card>
</Col>
))}
</Row>
)}
</div>
);
}

View File

@ -492,7 +492,7 @@ export default function VolunteerMapPage() {
<MapCrosshair onClick={handleAddAtCenter} />
{/* North compass — bottom-left, just above footer */}
<NorthCompass style={{ bottom: FOOTER_HEIGHT + 8, top: 'auto', left: 8 }} />
<NorthCompass style={{ bottom: FOOTER_HEIGHT + 44 + 12, top: 'auto', left: 8 }} />
{/* Bottom control panel — all controls consolidated */}
<BottomControlPanel

View File

@ -12,6 +12,7 @@ import {
Grid,
App,
theme,
Alert,
} from 'antd';
import {
CalendarOutlined,
@ -19,9 +20,13 @@ import {
EnvironmentOutlined,
TeamOutlined,
CheckCircleOutlined,
SendOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import { useCanvassStore } from '@/stores/canvass.store';
const { Title, Text, Paragraph } = Typography;
@ -65,6 +70,8 @@ export default function VolunteerShiftsPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const navigate = useNavigate();
const activeSession = useCanvassStore((s) => s.session);
const [tab, setTab] = useState<Tab>('upcoming');
const [shifts, setShifts] = useState<VolunteerShift[]>([]);
@ -282,6 +289,29 @@ export default function VolunteerShiftsPage() {
<div>
<Typography.Title level={3} style={{ marginBottom: 16 }}>Shifts</Typography.Title>
{/* Active session resume banner */}
{activeSession?.cutId && (
<Alert
type="success"
showIcon
icon={<PlayCircleOutlined />}
message="You have an active canvass session"
action={
<Button size="small" type="primary" onClick={() => navigate(`/volunteer/canvass/${activeSession.cutId}`)}>
Resume
</Button>
}
style={{ marginBottom: 16 }}
/>
)}
{/* Campaign context link */}
<Card size="small" style={{ marginBottom: 16 }}>
<SendOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
<Text type="secondary">See what you're working toward </Text>
<Link to="/campaigns" style={{ color: token.colorPrimary }}>View Campaigns</Link>
</Card>
<Segmented
value={tab}
onChange={(val) => setTab(val as Tab)}

View File

@ -600,6 +600,7 @@ export interface MapSettings {
qrCode2Label: string | null;
qrCode3Url: string | null;
qrCode3Label: string | null;
publicMapEnabled: boolean;
createdAt: string;
updatedAt: string;
}
@ -1066,6 +1067,17 @@ export interface SiteSettings {
smtpActiveProvider?: 'mailhog' | 'production';
emailTestMode?: boolean;
testEmailRecipient?: string;
_effective?: {
provider: 'mailhog' | 'production';
host: string;
port: number;
user: string;
hasPassword: boolean;
fromAddress: string;
fromName: string;
testMode: boolean;
testRecipient: string;
};
enablePublicRegistration: boolean;
enableEmailVerification: boolean;
autoApproveVerifiedUsers: boolean;
@ -1074,10 +1086,38 @@ export interface SiteSettings {
enableNewsletter: boolean;
enableLandingPages: boolean;
enableMediaFeatures: boolean;
enablePayments: boolean;
enableGalleryAds: boolean;
createdAt: string;
updatedAt: string;
}
// --- MkDocs Header Builder ---
export interface HeaderNavItem {
id: string;
label: string;
path: string;
icon?: string;
enabled: boolean;
order: number;
type: 'builtin' | 'custom';
openInNewTab?: boolean;
}
export interface HeaderStyle {
backgroundColor: string;
textColor: string;
hoverColor: string;
height: string;
}
export interface HeaderConfig {
enabled: boolean;
items: HeaderNavItem[];
style: HeaderStyle;
}
// --- MkDocs Config ---
export interface MkDocsConfigResponse {
@ -1683,3 +1723,180 @@ export interface ContainerResource {
export interface ContainerResourcesResponse {
containers: ContainerResource[];
}
// --- Payments ---
export interface SubscriptionPlan {
id: number;
name: string;
priceCAD: number;
durationDays: number;
yearlyPriceCAD: number | null;
features: string[] | null;
description: string | null;
tier: number;
displayOrder: number;
isActive: boolean;
stripeProductId: string | null;
stripePriceId: string | null;
stripeYearlyPriceId: string | null;
createdAt: string;
}
export interface UserSubscription {
id: number;
userId: string;
planId: number;
status: string;
startDate: string;
endDate: string;
cancelledAt: string | null;
stripeSubscriptionId: string | null;
stripeCustomerId: string | null;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
createdAt: string;
plan?: SubscriptionPlan;
user?: { id: string; email: string; name: string | null };
}
export type ProductType = 'DIGITAL' | 'EVENT' | 'DONATION';
export type OrderStatus = 'PENDING' | 'COMPLETED' | 'FAILED' | 'REFUNDED';
export interface Product {
id: string;
slug: string;
title: string;
description: string | null;
priceCAD: number;
type: ProductType;
stripeProductId: string | null;
stripePriceId: string | null;
isActive: boolean;
imageUrl: string | null;
downloadUrl: string | null;
metadata: Record<string, unknown> | null;
maxPurchases: number | null;
purchaseCount: number;
createdAt: string;
updatedAt: string;
}
export interface Order {
id: string;
userId: string | null;
productId: string | null;
amountCAD: number;
status: OrderStatus;
stripeCheckoutSessionId: string | null;
stripePaymentIntentId: string | null;
type: string;
buyerEmail: string;
buyerName: string | null;
donorMessage: string | null;
isAnonymous: boolean;
completedAt: string | null;
createdAt: string;
updatedAt: string;
product?: { id: string; title: string; slug: string; type: ProductType } | null;
user?: { id: string; email: string; name: string | null } | null;
}
export interface PaymentSettings {
id: string;
stripeSecretKey: string;
stripePublishableKey: string;
stripeWebhookSecret: string;
defaultCurrency: string;
enableDonations: boolean;
donationSuggestedAmounts: number[];
donationMinimum: number;
donationPageTitle: string;
donationPageDescription: string | null;
thankYouMessage: string;
createdAt: string;
updatedAt: string;
}
export interface PaymentDashboardStats {
activeSubscribers: number;
totalRevenue: number;
mrr: number;
planCount: number;
donations: {
totalDonations: number;
totalAmount: number;
recentDonations: Order[];
};
}
// --- Campaign Effectiveness ---
export interface CampaignOverviewStats {
summary: {
totalEmails: number;
totalResponses: number;
totalCalls: number;
activeCampaigns: number;
totalCampaigns: number;
avgResponseRate: number;
};
campaigns: CampaignEffectivenessRow[];
}
export interface CampaignEffectivenessRow {
campaignId: string;
title: string;
slug: string;
status: CampaignStatus;
createdAt: string;
emailTotal: number;
emailBreakdown: Record<string, number>;
responseTotal: number;
approvedResponses: number;
responseBreakdown: Record<string, number>;
responseRate: number;
callCount: number;
}
export interface RepEffectivenessData {
representatives: RepEffectivenessRow[];
totalRepresentatives: number;
levelDistribution: Array<{ level: string; count: number }>;
}
export interface RepEffectivenessRow {
name: string;
email: string;
level: string | null;
emailsReceived: number;
responsesGiven: number;
verifiedCount: number;
responseRate: number;
}
export interface GeoBreakdownData {
groupBy: 'province' | 'city' | 'postalCode';
data: GeoBreakdownRow[];
}
export interface GeoBreakdownRow {
key: string;
emailCount: number;
city: string | null;
province: string | null;
}
export interface FunnelStage {
name: string;
count: number;
percentOfFirst: number;
dropoff: number;
}
export interface ActivityTrendsData {
granularity: 'day' | 'week';
dateFrom: string;
dateTo: string;
series: Array<{ date: string; emails: number; responses: number }>;
}

View File

@ -0,0 +1,63 @@
export type AdType = 'system' | 'payment_subscribe' | 'payment_donate' | 'payment_shop' | 'custom';
export type AdVariant = 'standard' | 'highlight' | 'minimal';
export type AdCtaStyle = 'primary' | 'outline' | 'link';
export type AdVisibility = 'everyone' | 'anonymous' | 'authenticated' | 'non_subscriber';
export interface GalleryAd {
id: number;
type: AdType;
variant: AdVariant;
title: string;
subtitle: string | null;
imagePath: string | null;
linkUrl: string | null;
ctaText: string | null;
ctaStyle: AdCtaStyle;
bgColor: string | null;
iconEmoji: string | null;
visibility: AdVisibility;
isActive: boolean;
position: number;
frequency: number;
isSystemAd: boolean;
productId: string | null;
impressionCount: number;
clickCount: number;
startDate: string | null;
endDate: string | null;
createdAt: string;
updatedAt: string | null;
}
export const AD_TYPE_LABELS: Record<AdType, string> = {
system: 'System',
payment_subscribe: 'Subscribe',
payment_donate: 'Donate',
payment_shop: 'Shop',
custom: 'Custom',
};
export const AD_TYPE_COLORS: Record<AdType, string> = {
system: 'blue',
payment_subscribe: 'purple',
payment_donate: 'green',
payment_shop: 'orange',
custom: 'default',
};
export const AD_VISIBILITY_LABELS: Record<AdVisibility, string> = {
everyone: 'Everyone',
anonymous: 'Anonymous Only',
authenticated: 'Logged-In Only',
non_subscriber: 'Non-Subscribers',
};
export const AD_VISIBILITY_COLORS: Record<AdVisibility, string> = {
everyone: 'blue',
anonymous: 'green',
authenticated: 'purple',
non_subscriber: 'orange',
};

View File

@ -32,6 +32,7 @@ export interface Video {
isPublished?: boolean;
publishedAt?: string | null;
isShort?: boolean;
accessLevel?: 'free' | 'member' | 'premium';
}
// Video Analytics interfaces

View File

@ -0,0 +1,70 @@
import type { GalleryAd } from '@/types/gallery-ads';
export type GridItem<T> =
| { type: 'video'; data: T }
| { type: 'ad'; data: GalleryAd };
/**
* Merge ads into a video grid based on each ad's frequency setting.
*
* Algorithm:
* 1. Build a map of desired insertion slots for each ad
* (ad with frequency N inserts after every Nth video: positions N-1, 2N-1, 3N-1, ...)
* 2. Resolve collisions: higher priority (lower position number) wins the slot,
* lower-priority ads shift to the next available slot
* 3. Return merged array with videos and ads interleaved
*/
export function mergeAdsIntoGrid<T extends { id: number }>(
videos: T[],
ads: GalleryAd[]
): GridItem<T>[] {
if (ads.length === 0) {
return videos.map((v) => ({ type: 'video' as const, data: v }));
}
// Sort ads by position (priority) — lower position = higher priority
const sortedAds = [...ads].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
// Calculate target slots for each ad
// slot index is in "video-only" space (0-indexed)
const slotAssignments = new Map<number, GalleryAd>();
const usedSlots = new Set<number>();
for (const ad of sortedAds) {
const freq = ad.frequency;
// Insert after every freq-th video: at indices freq-1, 2*freq-1, etc.
for (let i = freq - 1; i < videos.length; i += freq) {
let targetSlot = i;
// If slot is taken, find next available
while (usedSlots.has(targetSlot) && targetSlot < videos.length + ads.length) {
targetSlot++;
}
if (!usedSlots.has(targetSlot)) {
slotAssignments.set(targetSlot, ad);
usedSlots.add(targetSlot);
break; // Each ad appears at most once per page
}
}
}
// Build merged array
const result: GridItem<T>[] = [];
let videoIdx = 0;
for (let pos = 0; videoIdx < videos.length || slotAssignments.has(pos); pos++) {
const adForSlot = slotAssignments.get(pos);
if (adForSlot) {
const adItem: GridItem<T> = { type: 'ad', data: adForSlot };
result.push(adItem);
}
if (videoIdx < videos.length) {
result.push({ type: 'video', data: videos[videoIdx]! });
videoIdx++;
}
}
return result;
}

File diff suppressed because one or more lines are too long

View File

@ -12,6 +12,9 @@ RUN npx prisma generate
# Development stage
FROM base AS development
COPY . .
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["npm", "run", "dev"]
# Build stage
@ -26,4 +29,7 @@ COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["npm", "start"]

13
api/docker-entrypoint.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/sh
set -e
echo "Running Prisma schema sync..."
npx prisma db push --skip-generate 2>&1
echo "Schema sync complete."
echo "Running database seed..."
npx prisma db seed 2>&1
echo "Seed complete."
echo "Starting server..."
exec "$@"

17
api/package-lock.json generated
View File

@ -35,6 +35,7 @@
"prom-client": "^15.1.3",
"qrcode": "^1.5.4",
"rate-limit-redis": "^4.2.0",
"stripe": "^20.3.1",
"winston": "^3.17.0",
"yaml": "^2.8.2",
"zod": "^3.24.1"
@ -4799,6 +4800,22 @@
"node": ">=8"
}
},
"node_modules/stripe": {
"version": "20.3.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz",
"integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"@types/node": ">=16"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/tdigest": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz",

View File

@ -43,6 +43,7 @@
"prom-client": "^15.1.3",
"qrcode": "^1.5.4",
"rate-limit-redis": "^4.2.0",
"stripe": "^20.3.1",
"winston": "^3.17.0",
"yaml": "^2.8.2",
"zod": "^3.24.1"

View File

@ -130,6 +130,7 @@ model User {
invoices Invoice[] @relation("UserInvoices")
payments Payment[] @relation("UserPayments")
paymentAudits PaymentAuditLog[] @relation("PaymentAuditUser")
orders Order[] @relation("UserOrders")
notifications Notification[] @relation("UserNotifications")
notificationPreferences NotificationPreferences? @relation("NotificationPreferences")
@ -293,6 +294,8 @@ model CampaignEmail {
@@index([campaignId])
@@index([campaignSlug])
@@index([userPostalCode])
@@index([sentAt])
@@map("campaign_emails")
}
@ -357,6 +360,7 @@ model RepresentativeResponse {
@@index([campaignId])
@@index([campaignSlug])
@@index([representativeName])
@@map("representative_responses")
}
@ -779,6 +783,7 @@ model MapSettings {
qrCode2Label String?
qrCode3Url String?
qrCode3Label String?
publicMapEnabled Boolean @default(true)
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -836,6 +841,9 @@ model SiteSettings {
enableMap Boolean @default(true)
enableNewsletter Boolean @default(true)
enableLandingPages Boolean @default(true)
enableMediaFeatures Boolean @default(true) @map("enable_media_features")
enablePayments Boolean @default(false)
enableGalleryAds Boolean @default(false) @map("enable_gallery_ads")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -851,6 +859,7 @@ enum EmailTemplateCategory {
INFLUENCE
MAP
SYSTEM
PAYMENT
}
enum EmailTemplateVariableType {
@ -1305,6 +1314,7 @@ enum SubscriptionStatus {
grace_period
delinquent
lifetime
cancelled
}
enum InvoiceStatus {
@ -1318,12 +1328,27 @@ enum PaymentStatus {
pending
succeeded
failed
refunded
}
enum PaymentMethod {
card
bank_transfer
crypto
stripe
}
enum ProductType {
DIGITAL
EVENT
DONATION
}
enum OrderStatus {
PENDING
COMPLETED
FAILED
REFUNDED
}
enum NotificationType {
@ -1402,6 +1427,9 @@ model Video {
averageWatchTimeSeconds Decimal @default(0) @map("average_watch_time_seconds") @db.Decimal(10, 2)
completionRate Decimal @default(0) @map("completion_rate") @db.Decimal(5, 2)
// Content gating
accessLevel String @default("free") @map("access_level") // free|member|premium
// Ordering
position Int? @default(0)
@ -2018,17 +2046,28 @@ model Ad {
imagePath String? @map("image_path")
linkUrl String? @map("link_url")
title String?
subtitle String? @db.Text
ctaText String? @map("cta_text")
ctaStyle String? @default("primary") @map("cta_style")
bgColor String? @map("bg_color")
iconEmoji String? @map("icon_emoji")
isSystemAd Boolean @default(false) @map("is_system_ad")
frequency Int @default(6)
visibility String @default("everyone")
isActive Boolean? @default(true) @map("is_active")
position Int? @default(0)
impressionCount Int? @default(0) @map("impression_count")
clickCount Int? @default(0) @map("click_count")
startDate DateTime? @map("start_date")
endDate DateTime? @map("end_date")
productId String? @unique @map("product_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @map("updated_at")
// Relations
impressions AdImpression[]
clicks AdClick[]
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
@@index([type], map: "idx_ads_type")
@@index([isActive], map: "idx_ads_is_active")
@ -3039,13 +3078,22 @@ model PipelineTemplate {
// ============================================================================
model SubscriptionPlan {
id Int @id @default(autoincrement())
name String
priceCAD Int @map("price_cad")
durationDays Int @map("duration_days")
features Json?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
name String
priceCAD Int @map("price_cad")
durationDays Int @map("duration_days")
features Json?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
// Stripe integration
stripeProductId String? @map("stripe_product_id")
stripePriceId String? @map("stripe_price_id")
stripeYearlyPriceId String? @map("stripe_yearly_price_id")
yearlyPriceCAD Int? @map("yearly_price_cad")
description String? @db.Text
tier Int @default(0)
displayOrder Int @default(0) @map("display_order")
// Relations
subscriptions UserSubscription[]
@ -3054,14 +3102,20 @@ model SubscriptionPlan {
}
model UserSubscription {
id Int @id @default(autoincrement())
userId String @map("user_id")
planId Int @map("plan_id")
status SubscriptionStatus @default(active)
startDate DateTime @map("start_date")
endDate DateTime @map("end_date")
cancelledAt DateTime? @map("cancelled_at")
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
userId String @map("user_id")
planId Int @map("plan_id")
status SubscriptionStatus @default(active)
startDate DateTime @map("start_date")
endDate DateTime @map("end_date")
cancelledAt DateTime? @map("cancelled_at")
createdAt DateTime @default(now()) @map("created_at")
// Stripe integration
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
stripeCustomerId String? @map("stripe_customer_id")
currentPeriodEnd DateTime? @map("current_period_end")
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
// Relations
user User @relation("UserSubscriptions", fields: [userId], references: [id])
@ -3085,6 +3139,10 @@ model Invoice {
description String?
metadata Json?
// Stripe integration
stripeInvoiceId String? @unique @map("stripe_invoice_id")
type String @default("subscription") @map("invoice_type") // subscription|product|donation
// Relations
user User @relation("UserInvoices", fields: [userId], references: [id])
payments Payment[]
@ -3096,16 +3154,20 @@ model Invoice {
}
model Payment {
id Int @id @default(autoincrement())
invoiceId Int @map("invoice_id")
userId String @map("user_id")
amountCAD Int @map("amount_cad")
method PaymentMethod
status PaymentStatus @default(pending)
externalId String? @map("external_id")
metadata Json?
processedAt DateTime? @map("processed_at")
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
invoiceId Int @map("invoice_id")
userId String @map("user_id")
amountCAD Int @map("amount_cad")
method PaymentMethod
status PaymentStatus @default(pending)
externalId String? @map("external_id")
metadata Json?
processedAt DateTime? @map("processed_at")
createdAt DateTime @default(now()) @map("created_at")
// Stripe integration
stripePaymentIntentId String? @unique @map("stripe_payment_intent_id")
stripeCheckoutSessionId String? @map("stripe_checkout_session_id")
// Relations
invoice Invoice @relation(fields: [invoiceId], references: [id])
@ -3138,6 +3200,87 @@ model PaymentAuditLog {
@@map("payment_audit_log")
}
model Product {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
priceCAD Int @map("price_cad")
type ProductType
stripeProductId String? @map("stripe_product_id")
stripePriceId String? @map("stripe_price_id")
isActive Boolean @default(true) @map("is_active")
imageUrl String? @map("image_url")
downloadUrl String? @map("download_url")
metadata Json?
maxPurchases Int? @map("max_purchases")
purchaseCount Int @default(0) @map("purchase_count")
createdByUserId String? @map("created_by_user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
orders Order[]
galleryAd Ad?
@@index([type], map: "idx_products_type")
@@index([isActive], map: "idx_products_active")
@@map("products")
}
model Order {
id String @id @default(cuid())
userId String? @map("user_id")
productId String? @map("product_id")
amountCAD Int @map("amount_cad")
status OrderStatus @default(PENDING)
stripeCheckoutSessionId String? @unique @map("stripe_checkout_session_id")
stripePaymentIntentId String? @map("stripe_payment_intent_id")
type String @default("product") @map("order_type") // product|donation
// Buyer info (for guests)
buyerEmail String @map("buyer_email")
buyerName String? @map("buyer_name")
// Donation-specific
donorMessage String? @db.Text @map("donor_message")
isAnonymous Boolean @default(false) @map("is_anonymous")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User? @relation("UserOrders", fields: [userId], references: [id])
product Product? @relation(fields: [productId], references: [id])
@@index([userId], map: "idx_orders_user")
@@index([productId], map: "idx_orders_product")
@@index([status], map: "idx_orders_status")
@@index([type], map: "idx_orders_type")
@@map("orders")
}
model PaymentSettings {
id String @id @default(cuid())
stripeSecretKey String @default("") @map("stripe_secret_key")
stripePublishableKey String @default("") @map("stripe_publishable_key")
stripeWebhookSecret String @default("") @map("stripe_webhook_secret")
defaultCurrency String @default("cad") @map("default_currency")
// Donation settings
enableDonations Boolean @default(true) @map("enable_donations")
donationSuggestedAmounts Json @default("[1000, 2500, 5000, 10000]") @map("donation_suggested_amounts")
donationMinimum Int @default(500) @map("donation_minimum")
donationPageTitle String @default("Support Our Work") @map("donation_page_title")
donationPageDescription String? @db.Text @map("donation_page_description")
thankYouMessage String @default("Thank you for your support!") @db.Text @map("thank_you_message")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("payment_settings")
}
// ============================================================================
// NOTIFICATIONS
// ============================================================================
@ -3299,3 +3442,20 @@ model VideoScheduleHistory {
@@index([scheduledByUserId], map: "idx_video_schedule_history_user")
@@map("video_schedule_history")
}
// ============================================================================
// DOCS ANALYTICS
// ============================================================================
model DocsPageView {
id String @id @default(cuid())
path String // e.g. "/docs/getting-started/"
referrer String? @db.Text // document.referrer
sessionHash String? // anonymous session UUID (sessionStorage)
userAgent String? // for device type breakdown
createdAt DateTime @default(now())
@@index([createdAt])
@@index([path, createdAt])
@@map("docs_page_views")
}

View File

@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
import * as fs from 'fs';
import * as path from 'path';
import { env } from '../src/config/env';
import { initEncryption, encrypt } from '../src/utils/crypto';
const prisma = new PrismaClient();
@ -77,12 +78,32 @@ async function main() {
console.log('Created default map settings');
// Phase 3: v1 data migration will go here
// - Export NocoDB data via API
// - Import influence_users + login tables → unified users
// - Deduplicate by email
// - Hash plaintext passwords
// - Import campaigns, locations, shifts, cuts, etc.
// Seed SiteSettings with .env SMTP values (only if no row exists yet)
const existingSettings = await prisma.siteSettings.findFirst();
if (!existingSettings) {
// Initialize encryption so we can encrypt the SMTP password
const encryptionKey = env.ENCRYPTION_KEY || env.JWT_ACCESS_SECRET;
initEncryption(encryptionKey);
const isMailhog = env.EMAIL_TEST_MODE === 'true' || env.SMTP_HOST === 'mailhog-changemaker';
await prisma.siteSettings.create({
data: {
smtpHost: env.SMTP_HOST,
smtpPort: env.SMTP_PORT,
smtpUser: env.SMTP_USER,
smtpPass: env.SMTP_PASS ? encrypt(env.SMTP_PASS) : '',
smtpFromAddress: env.SMTP_FROM,
emailFromName: env.SMTP_FROM_NAME,
smtpActiveProvider: isMailhog ? 'mailhog' : 'production',
emailTestMode: env.EMAIL_TEST_MODE === 'true',
testEmailRecipient: env.TEST_EMAIL_RECIPIENT,
},
});
console.log('Created SiteSettings with SMTP config from .env');
} else {
console.log('SiteSettings already exists, skipping SMTP seeding');
}
// Create default page blocks for landing page builder
const defaultBlocks = [
@ -239,6 +260,57 @@ async function main() {
viewCount: 0,
},
},
{
id: 'default-donate-button',
type: 'donate-button',
label: 'Donate Button',
category: 'Payments',
sortOrder: 9,
schema: {
buttonText: { type: 'string', label: 'Button Text', default: 'Donate Now' },
showAmounts: { type: 'boolean', label: 'Show Suggested Amounts', default: true },
heading: { type: 'string', label: 'Heading', default: 'Support Our Cause' },
description: { type: 'string', label: 'Description' },
},
defaults: {
buttonText: 'Donate Now',
showAmounts: true,
heading: 'Support Our Cause',
description: 'Your contribution helps us create lasting change in our community.',
},
},
{
id: 'default-pricing-table',
type: 'pricing-table',
label: 'Pricing Table',
category: 'Payments',
sortOrder: 10,
schema: {
showYearly: { type: 'boolean', label: 'Show Yearly Toggle', default: true },
heading: { type: 'string', label: 'Heading', default: 'Choose Your Plan' },
description: { type: 'string', label: 'Description' },
},
defaults: {
showYearly: true,
heading: 'Choose Your Plan',
description: 'Get access to exclusive content and features.',
},
},
{
id: 'default-product-card',
type: 'product-card',
label: 'Product Card',
category: 'Payments',
sortOrder: 11,
schema: {
productSlug: { type: 'string', label: 'Product Slug', required: true },
buttonText: { type: 'string', label: 'Button Text', default: 'Buy Now' },
},
defaults: {
productSlug: '',
buttonText: 'Buy Now',
},
},
];
for (const block of defaultBlocks) {
@ -258,6 +330,9 @@ async function main() {
console.warn('⚠️ No admin user found - skipping email template seeding');
}
// Seed pre-made gallery ads (all inactive by default — admin enables manually)
await seedGalleryAds();
console.log('Seed complete.');
}
@ -404,6 +479,58 @@ async function seedEmailTemplates(admin: { id: string; email: string }) {
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 4 },
],
},
{
key: 'donation-receipt',
name: 'Donation Receipt',
description: 'Receipt email sent to donors after a successful donation payment',
category: EmailTemplateCategory.PAYMENT,
subjectLine: 'Donation Receipt — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'Donor Name', description: 'Name of the donor', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'AMOUNT', label: 'Amount', description: 'Donation amount formatted with dollar sign', isRequired: true, isConditional: false, sampleValue: '$25.00', sortOrder: 1 },
{ key: 'ORDER_ID', label: 'Order ID', description: 'Unique reference ID for the donation', isRequired: true, isConditional: false, sampleValue: 'clxyz123abc', sortOrder: 2 },
{ key: 'DONATION_DATE', label: 'Donation Date', description: 'Date the donation was completed', isRequired: true, isConditional: false, sampleValue: 'February 17, 2026', sortOrder: 3 },
{ key: 'DONOR_MESSAGE', label: 'Donor Message', description: 'Optional message from the donor', isRequired: false, isConditional: true, sampleValue: 'Keep up the great work!', sortOrder: 4 },
{ key: 'IS_ANONYMOUS', label: 'Is Anonymous', description: 'Whether the donation is anonymous', isRequired: false, isConditional: true, sampleValue: 'true', sortOrder: 5 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 6 },
],
},
{
key: 'product-receipt',
name: 'Product Purchase Receipt',
description: 'Receipt email sent to buyers after a successful product purchase',
category: EmailTemplateCategory.PAYMENT,
subjectLine: 'Purchase Receipt — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'Buyer Name', description: 'Name of the buyer', isRequired: true, isConditional: false, sampleValue: 'John Smith', sortOrder: 0 },
{ key: 'AMOUNT', label: 'Amount', description: 'Purchase amount formatted with dollar sign', isRequired: true, isConditional: false, sampleValue: '$49.99', sortOrder: 1 },
{ key: 'ORDER_ID', label: 'Order ID', description: 'Unique order reference ID', isRequired: true, isConditional: false, sampleValue: 'clxyz456def', sortOrder: 2 },
{ key: 'PRODUCT_TITLE', label: 'Product Title', description: 'Title of the purchased product', isRequired: true, isConditional: false, sampleValue: 'Community Toolkit', sortOrder: 3 },
{ key: 'PRODUCT_TYPE', label: 'Product Type', description: 'Type of product (DIGITAL, EVENT, DONATION)', isRequired: true, isConditional: false, sampleValue: 'DIGITAL', sortOrder: 4 },
{ key: 'PURCHASE_DATE', label: 'Purchase Date', description: 'Date of purchase', isRequired: true, isConditional: false, sampleValue: 'February 17, 2026', sortOrder: 5 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 6 },
],
},
{
key: 'subscription-welcome',
name: 'Subscription Welcome',
description: 'Welcome email sent when a user subscribes to a plan',
category: EmailTemplateCategory.PAYMENT,
subjectLine: 'Welcome to {{PLAN_NAME}} — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'User Name', description: 'Name of the subscriber', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'PLAN_NAME', label: 'Plan Name', description: 'Name of the subscription plan', isRequired: true, isConditional: false, sampleValue: 'Pro Plan', sortOrder: 1 },
{ key: 'AMOUNT', label: 'Amount', description: 'Subscription price formatted with dollar sign', isRequired: true, isConditional: false, sampleValue: '$9.99', sortOrder: 2 },
{ key: 'FREQUENCY', label: 'Billing Frequency', description: 'How often the subscription renews', isRequired: true, isConditional: false, sampleValue: 'per month', sortOrder: 3 },
{ key: 'RENEWAL_DATE', label: 'Renewal Date', description: 'Next renewal date', isRequired: true, isConditional: false, sampleValue: 'March 17, 2026', sortOrder: 4 },
{ key: 'SUBSCRIPTION_ID', label: 'Subscription ID', description: 'Stripe subscription ID', isRequired: true, isConditional: false, sampleValue: 'sub_1234567890', sortOrder: 5 },
{ key: 'LOGIN_URL', label: 'Login URL', description: 'URL to log in to the platform', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/login', sortOrder: 6 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 7 },
],
},
];
let seededCount = 0;
@ -476,6 +603,131 @@ async function seedEmailTemplates(admin: { id: string; email: string }) {
console.log(`Email templates seeded: ${seededCount} created, ${skippedCount} skipped`);
}
/**
* Seed pre-made gallery ads
*/
async function seedGalleryAds() {
console.log('Seeding gallery ads...');
const defaultAds = [
{
type: 'system',
variant: 'standard',
title: 'Join the Community',
subtitle: 'Create a free account to upvote, comment, and save favorites',
ctaText: 'Sign Up Free',
ctaStyle: 'primary',
linkUrl: '/login',
visibility: 'anonymous',
frequency: 8,
position: 1,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'payment_subscribe',
variant: 'highlight',
title: 'Unlock Premium Content',
subtitle: 'Subscribe for exclusive videos, early access, and more',
ctaText: 'View Plans',
ctaStyle: 'primary',
linkUrl: '/pricing',
visibility: 'non_subscriber',
frequency: 12,
position: 2,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'payment_donate',
variant: 'standard',
title: 'Support Our Mission',
subtitle: 'Your donation helps us create more content',
ctaText: 'Donate Now',
ctaStyle: 'primary',
linkUrl: '/donate',
visibility: 'everyone',
frequency: 18,
position: 3,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'payment_shop',
variant: 'standard',
title: 'Browse the Shop',
subtitle: 'Exclusive merchandise, downloads, and event tickets',
ctaText: 'Shop Now',
ctaStyle: 'primary',
linkUrl: '/shop',
visibility: 'everyone',
frequency: 24,
position: 4,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'system',
variant: 'standard',
title: 'Take Action',
subtitle: 'Join an advocacy campaign and make your voice heard',
ctaText: 'View Campaigns',
ctaStyle: 'primary',
linkUrl: '/campaigns',
visibility: 'everyone',
frequency: 18,
position: 5,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'system',
variant: 'standard',
title: 'Volunteer With Us',
subtitle: 'Sign up for a shift and help make a difference',
ctaText: 'See Shifts',
ctaStyle: 'primary',
linkUrl: '/shifts',
visibility: 'everyone',
frequency: 24,
position: 6,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
];
let seeded = 0;
let skipped = 0;
for (const ad of defaultAds) {
const existing = await prisma.ad.findFirst({
where: { type: ad.type, title: ad.title },
});
if (existing) {
skipped++;
continue;
}
await prisma.ad.create({
data: {
...ad,
isSystemAd: true,
isActive: false,
},
});
seeded++;
}
console.log(`Gallery ads seeded: ${seeded} created, ${skipped} skipped`);
}
main()
.catch((e) => {
console.error('Seed error:', e);

View File

@ -123,6 +123,9 @@ const envSchema = z.object({
OVERPASS_MIN_DELAY_MS: z.coerce.number().default(30000),
AREA_IMPORT_MAX_GRID_POINTS: z.coerce.number().default(500),
// Payments (Stripe)
ENABLE_PAYMENTS: z.string().default('false'),
// Media Management
ENABLE_MEDIA_FEATURES: z.string().default('false'),
MEDIA_API_PORT: z.coerce.number().default(4100),

View File

@ -139,6 +139,23 @@ export const canvassGeocodeRateLimit = rateLimit({
},
});
export const adTrackingRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // 60 events/min per IP (generous for scroll-heavy gallery pages)
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:ad-track:',
}),
message: {
error: {
message: 'Too many tracking requests',
code: 'RATE_LIMIT_EXCEEDED',
},
},
});
export const authRateLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // Reduced from 20 to prevent brute force attacks
@ -173,6 +190,23 @@ export const observabilityRateLimit = rateLimit({
},
});
export const docsAnalyticsRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // 60 requests/min per IP
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:docs-analytics:',
}),
message: {
error: {
message: 'Too many tracking requests, please slow down',
code: 'DOCS_ANALYTICS_RATE_LIMIT_EXCEEDED',
},
},
});
export const healthMetricsRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // 30 requests per minute

View File

@ -0,0 +1,64 @@
import { Router } from 'express';
import { UserRole } from '@prisma/client';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { docsAnalyticsRateLimit } from '../../middleware/rate-limit';
import { docsAnalyticsService } from './docs-analytics.service';
import { trackPageViewSchema, analyticsQuerySchema } from './docs-analytics.schemas';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
// --- Public Router (no auth) ---
export const docsAnalyticsPublicRouter = Router();
// Per-route CORS override: MkDocs runs on a different origin (root domain vs API subdomain)
docsAnalyticsPublicRouter.use((_req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
next();
});
// Handle preflight
docsAnalyticsPublicRouter.options('/track', (_req, res) => {
res.sendStatus(204);
});
// POST /api/docs-analytics/track — record page view (fire-and-forget)
docsAnalyticsPublicRouter.post(
'/track',
docsAnalyticsRateLimit,
validate(trackPageViewSchema),
async (req, res) => {
const { path, referrer, sessionHash } = req.body;
const userAgent = req.headers['user-agent'] || undefined;
// Fire-and-forget: don't await, respond immediately
docsAnalyticsService.recordPageView({ path, referrer, sessionHash, userAgent }).catch(() => {});
res.sendStatus(204);
},
);
// --- Admin Router (auth required) ---
export const docsAnalyticsAdminRouter = Router();
docsAnalyticsAdminRouter.use(authenticate);
docsAnalyticsAdminRouter.use(requireRole(...ADMIN_ROLES));
// GET /api/docs-analytics/summary?days=30
docsAnalyticsAdminRouter.get(
'/summary',
validate(analyticsQuerySchema, 'query'),
async (req, res) => {
const days = Number(req.query.days) || 30;
const summary = await docsAnalyticsService.getSummary(days);
res.json(summary);
},
);
// POST /api/docs-analytics/cleanup — manual cleanup trigger
docsAnalyticsAdminRouter.post('/cleanup', async (_req, res) => {
const deleted = await docsAnalyticsService.cleanupOldData(90);
res.json({ deleted });
});

View File

@ -0,0 +1,11 @@
import { z } from 'zod';
export const trackPageViewSchema = z.object({
path: z.string().min(1).max(2000),
referrer: z.string().max(2000).optional(),
sessionHash: z.string().max(100).optional(),
});
export const analyticsQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).default(30),
});

View File

@ -0,0 +1,140 @@
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
interface PageViewData {
path: string;
referrer?: string;
sessionHash?: string;
userAgent?: string;
}
interface TopPage {
path: string;
views: number;
uniqueSessions: number;
}
interface DayViews {
date: string;
views: number;
uniqueSessions: number;
}
interface TopReferrer {
referrer: string;
count: number;
}
interface AnalyticsSummary {
totalViews: number;
uniqueSessions: number;
topPages: TopPage[];
viewsByDay: DayViews[];
topReferrers: TopReferrer[];
}
type UniqueCountRow = { count: bigint };
type TopPageRow = { path: string; views: bigint; unique_sessions: bigint };
type DayViewRow = { day: Date; views: bigint; unique_sessions: bigint };
type ReferrerRow = { referrer: string; count: bigint };
export const docsAnalyticsService = {
async recordPageView(data: PageViewData): Promise<void> {
await prisma.docsPageView.create({
data: {
path: data.path,
referrer: data.referrer || null,
sessionHash: data.sessionHash || null,
userAgent: data.userAgent || null,
},
});
},
async getSummary(days: number): Promise<AnalyticsSummary> {
const since = new Date();
since.setDate(since.getDate() - days);
const totalViewsP = prisma.docsPageView.count({
where: { createdAt: { gte: since } },
});
const uniqueSessionsP = prisma.$queryRaw<UniqueCountRow[]>`
SELECT COUNT(DISTINCT "sessionHash") as count
FROM docs_page_views
WHERE "createdAt" >= ${since}
AND "sessionHash" IS NOT NULL
`;
const topPagesP = prisma.$queryRaw<TopPageRow[]>`
SELECT path,
COUNT(*) as views,
COUNT(DISTINCT "sessionHash") as unique_sessions
FROM docs_page_views
WHERE "createdAt" >= ${since}
GROUP BY path
ORDER BY views DESC
LIMIT 20
`;
const viewsByDayP = prisma.$queryRaw<DayViewRow[]>`
SELECT DATE("createdAt") as day,
COUNT(*) as views,
COUNT(DISTINCT "sessionHash") as unique_sessions
FROM docs_page_views
WHERE "createdAt" >= ${since}
GROUP BY DATE("createdAt")
ORDER BY day ASC
`;
const topReferrersP = prisma.$queryRaw<ReferrerRow[]>`
SELECT referrer,
COUNT(*) as count
FROM docs_page_views
WHERE "createdAt" >= ${since}
AND referrer IS NOT NULL
AND referrer != ''
GROUP BY referrer
ORDER BY count DESC
LIMIT 10
`;
const [totalViews, uniqueSessionsResult, topPagesRaw, viewsByDayRaw, topReferrersRaw] =
await Promise.all([totalViewsP, uniqueSessionsP, topPagesP, viewsByDayP, topReferrersP]);
return {
totalViews,
uniqueSessions: Number(uniqueSessionsResult[0]?.count ?? 0),
topPages: topPagesRaw.map((r) => ({
path: r.path,
views: Number(r.views),
uniqueSessions: Number(r.unique_sessions),
})),
viewsByDay: viewsByDayRaw.map((r) => ({
date: r.day instanceof Date
? r.day.toISOString().split('T')[0]
: String(r.day),
views: Number(r.views),
uniqueSessions: Number(r.unique_sessions),
})),
topReferrers: topReferrersRaw.map((r) => ({
referrer: r.referrer,
count: Number(r.count),
})),
};
},
async cleanupOldData(retentionDays = 90): Promise<number> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - retentionDays);
const { count } = await prisma.docsPageView.deleteMany({
where: { createdAt: { lt: cutoff } },
});
if (count > 0) {
logger.info(`Cleaned up ${count} docs page views older than ${retentionDays} days`);
}
return count;
},
};

View File

@ -10,6 +10,8 @@ import { isServiceOnline } from '../../utils/health-check';
import { cm_docs_operations } from '../../utils/metrics';
import { docsFilesService, PathTraversalError, FileNotFoundError } from './docs-files.service';
import { mkdocsConfigService } from './mkdocs-config.service';
import { headerBuilderService } from './header-builder.service';
import { headerConfigSchema } from './header-builder.schemas';
const router = Router();
router.use(authenticate);
@ -107,6 +109,46 @@ router.post(
},
);
// --- Header Builder ---
// GET /api/docs/header-config — read header nav bar config
router.get(
'/header-config',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const config = await headerBuilderService.readConfig();
res.json(config);
} catch (err) {
logger.error('Failed to read header config', err);
next(err);
}
},
);
// PUT /api/docs/header-config — save header nav bar config + regenerate template
router.put(
'/header-config',
requireRole('SUPER_ADMIN'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = headerConfigSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
error: { message: 'Invalid header config', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors },
});
return;
}
await headerBuilderService.writeConfig(parsed.data);
// Invalidate docs file tree cache so the new main.html shows up
await docsFilesService.invalidateTreeCache();
res.json({ success: true });
} catch (err) {
logger.error('Failed to save header config', err);
next(err);
}
},
);
// --- File Upload ---
const ALLOWED_UPLOAD_EXTENSIONS = new Set([

View File

@ -0,0 +1,29 @@
import { z } from 'zod';
export const headerNavItemSchema = z.object({
id: z.string().min(1),
label: z.string().min(1).max(50),
path: z.string().min(1).max(500),
icon: z.string().max(50).optional(),
enabled: z.boolean(),
order: z.number().int().min(0),
type: z.enum(['builtin', 'custom']),
openInNewTab: z.boolean().optional(),
});
export const headerStyleSchema = z.object({
backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a hex color'),
textColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a hex color'),
hoverColor: z.string().max(100),
height: z.string().regex(/^\d+px$/, 'Must be in px format'),
});
export const headerConfigSchema = z.object({
enabled: z.boolean(),
items: z.array(headerNavItemSchema).max(20),
style: headerStyleSchema,
});
export type HeaderNavItem = z.infer<typeof headerNavItemSchema>;
export type HeaderStyle = z.infer<typeof headerStyleSchema>;
export type HeaderConfig = z.infer<typeof headerConfigSchema>;

View File

@ -0,0 +1,240 @@
import { readFile, writeFile, unlink } from 'fs/promises';
import { resolve as pathResolve } from 'path';
import { existsSync } from 'fs';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { headerConfigSchema } from './header-builder.schemas';
import type { HeaderConfig, HeaderNavItem } from './header-builder.schemas';
const OVERRIDES_DIR = pathResolve(env.MKDOCS_DOCS_PATH, 'overrides');
const CONFIG_PATH = pathResolve(OVERRIDES_DIR, 'header-config.json');
const MAIN_HTML_PATH = pathResolve(OVERRIDES_DIR, 'main.html');
/** Default built-in navigation items (pre-populated when no config exists) */
const DEFAULT_ITEMS: HeaderNavItem[] = [
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'campaign', enabled: true, order: 0, type: 'builtin' },
{ id: 'map', label: 'Map', path: '/map', icon: 'map', enabled: true, order: 1, type: 'builtin' },
{ id: 'shifts', label: 'Volunteer', path: '/shifts', icon: 'groups', enabled: true, order: 2, type: 'builtin' },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'play_circle', enabled: false, order: 3, type: 'builtin' },
{ id: 'responses', label: 'Responses', path: '/responses', icon: 'forum', enabled: false, order: 4, type: 'builtin' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'favorite', enabled: false, order: 5, type: 'builtin' },
{ id: 'login', label: 'Sign In', path: '/login', icon: 'login', enabled: true, order: 6, type: 'builtin' },
];
const DEFAULT_CONFIG: HeaderConfig = {
enabled: false,
items: DEFAULT_ITEMS,
style: {
backgroundColor: '#6f42c1',
textColor: '#ffffff',
hoverColor: 'rgba(255,255,255,0.15)',
height: '40px',
},
};
/**
* Escape a string for safe embedding inside a Jinja2/HTML template.
* Prevents XSS if user-supplied labels or paths contain special chars.
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
class HeaderBuilderService {
/**
* Read the current header config from disk.
* Returns defaults if no config file exists.
*/
async readConfig(): Promise<HeaderConfig> {
try {
if (!existsSync(CONFIG_PATH)) {
return { ...DEFAULT_CONFIG };
}
const raw = await readFile(CONFIG_PATH, 'utf-8');
const parsed = JSON.parse(raw);
const validated = headerConfigSchema.parse(parsed);
return validated;
} catch (err) {
logger.warn('Failed to read header config, returning defaults', err);
return { ...DEFAULT_CONFIG };
}
}
/**
* Validate, save config, and regenerate main.html.
*/
async writeConfig(config: HeaderConfig): Promise<void> {
// Validate with Zod
const validated = headerConfigSchema.parse(config);
// Write config JSON
await writeFile(CONFIG_PATH, JSON.stringify(validated, null, 2), 'utf-8');
logger.info('Header config saved');
// Generate or remove main.html
if (validated.enabled) {
const html = this.generateMainHtml(validated);
await writeFile(MAIN_HTML_PATH, html, 'utf-8');
logger.info('Generated main.html with header nav bar');
} else {
// Write minimal passthrough so landing pages still extend main.html
const passthrough = '{# Auto-generated by Changemaker Lite Header Builder — header disabled #}\n{% extends "base.html" %}\n';
await writeFile(MAIN_HTML_PATH, passthrough, 'utf-8');
logger.info('Generated passthrough main.html (header disabled)');
}
}
/**
* Reset to defaults: remove config file and main.html.
*/
async resetToDefaults(): Promise<void> {
try { await unlink(CONFIG_PATH); } catch { /* file may not exist */ }
try { await unlink(MAIN_HTML_PATH); } catch { /* file may not exist */ }
logger.info('Header config reset to defaults');
}
/**
* Generate the Jinja2 main.html template from config.
*/
generateMainHtml(config: HeaderConfig): string {
const enabledItems = config.items
.filter((item) => item.enabled)
.sort((a, b) => a.order - b.order);
const links = enabledItems.map((item) => this.renderNavLink(item)).join('\n ');
const { backgroundColor, textColor, hoverColor, height } = config.style;
return `{# Auto-generated by Changemaker Lite Header Builder — do not edit manually #}
{% extends "base.html" %}
{% block announce %}
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<nav class="cm-header-nav" role="navigation" aria-label="Application">
<div class="cm-header-nav__inner">
${links}
</div>
</nav>
<script>
// Resolve nav link hrefs based on the current browser hostname.
// localhost → http://localhost:{ADMIN_PORT}
// subdomain.example.org → {proto}://app.example.org
(function() {
var h = location.hostname;
var base;
if (h === 'localhost' || h === '127.0.0.1') {
base = location.protocol + '//localhost:' + ({{ config.extra.admin_port }} || 3000);
} else {
var parts = h.split('.');
if (parts.length >= 3) { parts[0] = 'app'; }
else { parts.unshift('app'); }
base = location.protocol + '//' + parts.join('.');
}
var links = document.querySelectorAll('.cm-header-nav__link[data-path]');
for (var i = 0; i < links.length; i++) {
links[i].setAttribute('href', base + links[i].getAttribute('data-path'));
}
})();
</script>
<style>
/* Override MkDocs Material announce bar container */
.md-banner {
background: ${escapeHtml(backgroundColor)} !important;
color: ${escapeHtml(textColor)} !important;
}
/* Hide the dismiss (X) button that Material adds for announce.dismiss */
.md-banner__button {
display: none !important;
}
.cm-header-nav {
background: ${escapeHtml(backgroundColor)};
min-height: ${escapeHtml(height)};
display: flex;
align-items: center;
justify-content: center;
padding: 0 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
z-index: 10;
}
.cm-header-nav__inner {
display: flex;
align-items: center;
gap: 6px;
max-width: 1400px;
width: 100%;
justify-content: center;
flex-wrap: nowrap;
overflow-x: auto;
}
.cm-header-nav__link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
color: ${escapeHtml(textColor)};
text-decoration: none;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
border-radius: 6px;
background: rgba(255, 255, 255, 0.12);
transition: background 0.15s, transform 0.1s;
white-space: nowrap;
line-height: 1;
}
.cm-header-nav__link:hover {
background: ${escapeHtml(hoverColor)};
color: ${escapeHtml(textColor)};
text-decoration: none;
transform: translateY(-1px);
}
.cm-header-nav__link:active {
transform: translateY(0);
}
.cm-header-nav__link .material-icons {
font-size: 16px;
opacity: 0.9;
}
@media (max-width: 768px) {
.cm-header-nav { padding: 0 8px; }
.cm-header-nav__label { display: none; }
.cm-header-nav__link { padding: 8px 10px; }
.cm-header-nav__inner { gap: 4px; }
}
</style>
{% endblock %}
`;
}
/**
* Render a single nav link element.
*/
private renderNavLink(item: HeaderNavItem): string {
const isAbsolute = item.path.startsWith('http://') || item.path.startsWith('https://');
const target = item.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : '';
const iconHtml = item.icon
? `<span class="material-icons">${escapeHtml(item.icon)}</span>`
: '';
if (isAbsolute) {
// Absolute URLs: use href directly, no data-path (JS won't touch these)
return `<a href="${escapeHtml(item.path)}" class="cm-header-nav__link"${target}>
${iconHtml}
<span class="cm-header-nav__label">${escapeHtml(item.label)}</span>
</a>`;
}
// Relative paths: use data-path for JS resolution, href="#" as fallback
return `<a href="#" data-path="${escapeHtml(item.path)}" class="cm-header-nav__link"${target}>
${iconHtml}
<span class="cm-header-nav__label">${escapeHtml(item.label)}</span>
</a>`;
}
}
export const headerBuilderService = new HeaderBuilderService();

View File

@ -0,0 +1,125 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { galleryAdsService } from './gallery-ads.service';
import { createAdSchema, updateAdSchema, listAdsSchema, reorderAdsSchema, adAnalyticsQuerySchema } from './gallery-ads.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
const router = Router();
router.use(authenticate);
router.use(requireRole(UserRole.SUPER_ADMIN));
// GET /api/gallery-ads/admin — list all ads (paginated)
router.get(
'/',
validate(listAdsSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await galleryAdsService.listAll(req.query as any);
res.json(result);
} catch (err) {
next(err);
}
}
);
// GET /api/gallery-ads/admin/:id/analytics — per-ad time-series analytics
router.get(
'/:id/analytics',
validate(adAnalyticsQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
const { days } = req.query as any;
const analytics = await galleryAdsService.getAdAnalytics(id, days);
res.json(analytics);
} catch (err) {
next(err);
}
}
);
// GET /api/gallery-ads/admin/:id — get single ad
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
const ad = await galleryAdsService.getById(id);
if (!ad) {
res.status(404).json({ error: 'Ad not found' });
return;
}
res.json(ad);
} catch (err) {
next(err);
}
});
// POST /api/gallery-ads/admin — create ad
router.post(
'/',
validate(createAdSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const ad = await galleryAdsService.create(req.body);
res.status(201).json(ad);
} catch (err) {
next(err);
}
}
);
// PUT /api/gallery-ads/admin/reorder — bulk reorder
router.put(
'/reorder',
validate(reorderAdsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
await galleryAdsService.reorder(req.body.ids);
res.json({ success: true });
} catch (err) {
next(err);
}
}
);
// PUT /api/gallery-ads/admin/:id — update ad
router.put(
'/:id',
validate(updateAdSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
const ad = await galleryAdsService.update(id, req.body);
if (!ad) {
res.status(404).json({ error: 'Ad not found' });
return;
}
res.json(ad);
} catch (err) {
if (err instanceof Error && err.message.includes('Cannot change type')) {
res.status(400).json({ error: err.message });
return;
}
next(err);
}
}
);
// DELETE /api/gallery-ads/admin/:id — delete ad
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
await galleryAdsService.delete(id);
res.json({ success: true });
} catch (err) {
if (err instanceof Error && err.message.includes('Cannot delete')) {
res.status(400).json({ error: err.message });
return;
}
next(err);
}
});
export { router as galleryAdsAdminRouter };

View File

@ -0,0 +1,64 @@
import { Router, Request, Response, NextFunction } from 'express';
import { prisma } from '../../config/database';
import { galleryAdsService } from './gallery-ads.service';
import { trackAdSchema } from './gallery-ads.schemas';
import { validate } from '../../middleware/validate';
import { optionalAuth } from '../../middleware/auth.middleware';
import { adTrackingRateLimit } from '../../middleware/rate-limit';
const router = Router();
// GET /api/gallery-ads — get active ads for current viewer
router.get('/', optionalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const isAuthenticated = !!req.user;
let hasActiveSubscription = false;
if (req.user) {
const sub = await prisma.userSubscription.findFirst({
where: {
userId: req.user.id,
status: 'active',
endDate: { gte: new Date() },
},
});
hasActiveSubscription = !!sub;
}
const ads = await galleryAdsService.getActiveAds({
isAuthenticated,
hasActiveSubscription,
});
res.json(ads);
} catch (err) {
next(err);
}
});
// POST /api/gallery-ads/track — record impression or click
router.post(
'/track',
adTrackingRateLimit,
optionalAuth,
validate(trackAdSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { adId, event, sessionId } = req.body;
const userId = req.user?.id;
if (event === 'impression') {
await galleryAdsService.recordImpression(adId, sessionId, userId);
} else {
await galleryAdsService.recordClick(adId, sessionId, userId);
}
res.json({ success: true });
} catch {
// Silent fail for tracking — non-critical
res.json({ success: true });
}
}
);
export { router as galleryAdsPublicRouter };

View File

@ -0,0 +1,48 @@
import { z } from 'zod';
export const createAdSchema = z.object({
type: z.enum(['system', 'payment_subscribe', 'payment_donate', 'payment_shop', 'custom']),
variant: z.enum(['standard', 'highlight', 'minimal']).optional().default('standard'),
title: z.string().min(1).max(200),
subtitle: z.string().max(500).optional().nullable(),
imagePath: z.string().max(500).optional().nullable(),
linkUrl: z.string().max(500).optional().nullable(),
ctaText: z.string().max(100).optional().nullable(),
ctaStyle: z.enum(['primary', 'outline', 'link']).optional().default('primary'),
bgColor: z.string().max(20).optional().nullable(),
iconEmoji: z.string().max(10).optional().nullable(),
visibility: z.enum(['everyone', 'anonymous', 'authenticated', 'non_subscriber']).optional().default('everyone'),
isActive: z.boolean().optional().default(false),
position: z.number().int().min(0).optional().default(0),
frequency: z.number().int().min(1).max(24).optional().default(6),
startDate: z.string().datetime().optional().nullable(),
endDate: z.string().datetime().optional().nullable(),
});
export const updateAdSchema = createAdSchema.partial();
export const listAdsSchema = z.object({
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(50),
type: z.enum(['system', 'payment_subscribe', 'payment_donate', 'payment_shop', 'custom']).optional(),
isActive: z.enum(['true', 'false']).optional(),
});
export const reorderAdsSchema = z.object({
ids: z.array(z.number().int()).min(1),
});
export const trackAdSchema = z.object({
adId: z.number().int(),
event: z.enum(['impression', 'click']),
sessionId: z.string().uuid().optional(),
});
export const adAnalyticsQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).optional().default(30),
});
export type CreateAdInput = z.infer<typeof createAdSchema>;
export type UpdateAdInput = z.infer<typeof updateAdSchema>;
export type ListAdsInput = z.infer<typeof listAdsSchema>;
export type TrackAdInput = z.infer<typeof trackAdSchema>;

View File

@ -0,0 +1,298 @@
import { prisma } from '../../config/database';
import type { Prisma } from '@prisma/client';
import type { CreateAdInput, UpdateAdInput, ListAdsInput } from './gallery-ads.schemas';
interface ActiveAdsContext {
isAuthenticated: boolean;
hasActiveSubscription: boolean;
}
class GalleryAdsService {
/** Admin: paginated list of all ads */
async listAll(filters: ListAdsInput) {
const { page, limit, type, isActive } = filters;
const where: Prisma.AdWhereInput = {};
if (type) where.type = type;
if (isActive !== undefined) where.isActive = isActive === 'true';
const [ads, total] = await Promise.all([
prisma.ad.findMany({
where,
orderBy: [{ position: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * limit,
take: limit,
}),
prisma.ad.count({ where }),
]);
return {
ads,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
/** Admin: get single ad */
async getById(id: number) {
return prisma.ad.findUnique({ where: { id } });
}
/** Admin: create a new ad */
async create(data: CreateAdInput) {
return prisma.ad.create({
data: {
type: data.type,
variant: data.variant,
title: data.title,
subtitle: data.subtitle ?? null,
imagePath: data.imagePath ?? null,
linkUrl: data.linkUrl ?? null,
ctaText: data.ctaText ?? null,
ctaStyle: data.ctaStyle,
bgColor: data.bgColor ?? null,
iconEmoji: data.iconEmoji ?? null,
visibility: data.visibility,
isActive: data.isActive,
position: data.position,
frequency: data.frequency,
startDate: data.startDate ? new Date(data.startDate) : null,
endDate: data.endDate ? new Date(data.endDate) : null,
isSystemAd: false,
},
});
}
/** Admin: update an ad */
async update(id: number, data: UpdateAdInput) {
const existing = await prisma.ad.findUnique({ where: { id } });
if (!existing) return null;
// Block type change on system ads
if (existing.isSystemAd && data.type && data.type !== existing.type) {
throw new Error('Cannot change type of a system ad');
}
const updateData: Prisma.AdUncheckedUpdateInput = {};
if (data.type !== undefined) updateData.type = data.type;
if (data.variant !== undefined) updateData.variant = data.variant;
if (data.title !== undefined) updateData.title = data.title;
if (data.subtitle !== undefined) updateData.subtitle = data.subtitle;
if (data.imagePath !== undefined) updateData.imagePath = data.imagePath;
if (data.linkUrl !== undefined) updateData.linkUrl = data.linkUrl;
if (data.ctaText !== undefined) updateData.ctaText = data.ctaText;
if (data.ctaStyle !== undefined) updateData.ctaStyle = data.ctaStyle;
if (data.bgColor !== undefined) updateData.bgColor = data.bgColor;
if (data.iconEmoji !== undefined) updateData.iconEmoji = data.iconEmoji;
if (data.visibility !== undefined) updateData.visibility = data.visibility;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.position !== undefined) updateData.position = data.position;
if (data.frequency !== undefined) updateData.frequency = data.frequency;
if (data.startDate !== undefined) updateData.startDate = data.startDate ? new Date(data.startDate) : null;
if (data.endDate !== undefined) updateData.endDate = data.endDate ? new Date(data.endDate) : null;
updateData.updatedAt = new Date();
return prisma.ad.update({ where: { id }, data: updateData });
}
/** Admin: delete an ad (blocked for system ads) */
async delete(id: number) {
const existing = await prisma.ad.findUnique({ where: { id } });
if (!existing) return null;
if (existing.isSystemAd) {
throw new Error('Cannot delete a system ad');
}
return prisma.ad.delete({ where: { id } });
}
/** Admin: bulk reorder ads by position */
async reorder(ids: number[]) {
const ops = ids.map((id, index) =>
prisma.ad.update({
where: { id },
data: { position: index, updatedAt: new Date() },
})
);
await prisma.$transaction(ops);
}
/** Public: get active ads filtered by visibility, schedule, and feature gates */
async getActiveAds(context: ActiveAdsContext) {
const now = new Date();
// Check if gallery ads feature is enabled
const settings = await prisma.siteSettings.findFirst();
if (!settings?.enableGalleryAds) return [];
const ads = await prisma.ad.findMany({
where: {
isActive: true,
OR: [
{ startDate: null },
{ startDate: { lte: now } },
],
},
orderBy: { position: 'asc' },
});
// Filter in application layer for complex logic
return ads.filter((ad) => {
// End date check
if (ad.endDate && ad.endDate < now) return false;
// Visibility check
switch (ad.visibility) {
case 'anonymous':
if (context.isAuthenticated) return false;
break;
case 'authenticated':
if (!context.isAuthenticated) return false;
break;
case 'non_subscriber':
if (!context.isAuthenticated || context.hasActiveSubscription) return false;
break;
// 'everyone' always passes
}
// Payment-type ads only if payments enabled
if (['payment_subscribe', 'payment_donate', 'payment_shop'].includes(ad.type)) {
if (!settings.enablePayments) return false;
}
return true;
});
}
/**
* Ensure a Session record exists for FK integrity.
* Follows the same upsert pattern used by media upvote/comment routes.
*/
private async ensureSession(sessionId: string): Promise<void> {
await prisma.session.upsert({
where: { id: sessionId },
create: { id: sessionId },
update: { lastSeenAt: new Date() },
});
}
/** Public: increment impression count + create individual record */
async recordImpression(adId: number, sessionId?: string, userId?: string) {
if (sessionId) {
await this.ensureSession(sessionId);
}
await prisma.$transaction([
prisma.ad.update({
where: { id: adId },
data: { impressionCount: { increment: 1 } },
}),
prisma.adImpression.create({
data: {
adId,
sessionId: sessionId ?? null,
userId: userId ?? null,
},
}),
]);
}
/** Public: increment click count + create individual record */
async recordClick(adId: number, sessionId?: string, userId?: string) {
if (sessionId) {
await this.ensureSession(sessionId);
}
await prisma.$transaction([
prisma.ad.update({
where: { id: adId },
data: { clickCount: { increment: 1 } },
}),
prisma.adClick.create({
data: {
adId,
sessionId: sessionId ?? null,
userId: userId ?? null,
},
}),
]);
}
/**
* Admin: get per-ad analytics with daily breakdown.
* Counter fields on Ad remain for fast table reads; these individual records
* enable time-series queries. For high-traffic galleries, consider periodic
* cleanup of records older than 90 days (preserving counter totals).
*/
async getAdAnalytics(adId: number, days: number = 30) {
const since = new Date(Date.now() - days * 86400000);
const [dailyImpressions, dailyClicks, uniqueSessions, ad] = await Promise.all([
prisma.$queryRaw<{ date: string; count: bigint }[]>`
SELECT DATE("created_at") as date, COUNT(*) as count
FROM ad_impressions
WHERE ad_id = ${adId} AND created_at >= ${since}
GROUP BY DATE("created_at")
ORDER BY date
`,
prisma.$queryRaw<{ date: string; count: bigint }[]>`
SELECT DATE("created_at") as date, COUNT(*) as count
FROM ad_clicks
WHERE ad_id = ${adId} AND created_at >= ${since}
GROUP BY DATE("created_at")
ORDER BY date
`,
prisma.adImpression.findMany({
where: { adId, createdAt: { gte: since }, sessionId: { not: null } },
distinct: ['sessionId'],
select: { sessionId: true },
}),
prisma.ad.findUnique({
where: { id: adId },
select: { impressionCount: true, clickCount: true },
}),
]);
// Merge daily impressions + clicks into a single array
const dateMap = new Map<string, { impressions: number; clicks: number }>();
for (const row of dailyImpressions) {
const dateStr = new Date(row.date).toISOString().split('T')[0];
const entry = dateMap.get(dateStr) || { impressions: 0, clicks: 0 };
entry.impressions = Number(row.count);
dateMap.set(dateStr, entry);
}
for (const row of dailyClicks) {
const dateStr = new Date(row.date).toISOString().split('T')[0];
const entry = dateMap.get(dateStr) || { impressions: 0, clicks: 0 };
entry.clicks = Number(row.count);
dateMap.set(dateStr, entry);
}
const daily = Array.from(dateMap.entries())
.map(([date, counts]) => ({ date, ...counts }))
.sort((a, b) => a.date.localeCompare(b.date));
const totalImpressions = ad?.impressionCount ?? 0;
const totalClicks = ad?.clickCount ?? 0;
const ctr = totalImpressions > 0 ? Number(((totalClicks / totalImpressions) * 100).toFixed(1)) : 0;
return {
daily,
totals: {
impressions: totalImpressions,
clicks: totalClicks,
uniqueSessions: uniqueSessions.length,
ctr,
},
};
}
}
export const galleryAdsService = new GalleryAdsService();

View File

@ -0,0 +1,90 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { effectivenessService } from './effectiveness.service';
import {
effectivenessQuerySchema,
trendQuerySchema,
geoQuerySchema,
repQuerySchema,
} from './effectiveness.schemas';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN];
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
// GET /api/influence/effectiveness/overview
router.get(
'/overview',
validate(effectivenessQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await effectivenessService.getOverviewStats(req.query as any);
res.json(data);
} catch (err) {
next(err);
}
},
);
// GET /api/influence/effectiveness/representatives
router.get(
'/representatives',
validate(repQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await effectivenessService.getRepresentativeEffectiveness(req.query as any);
res.json(data);
} catch (err) {
next(err);
}
},
);
// GET /api/influence/effectiveness/geographic
router.get(
'/geographic',
validate(geoQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await effectivenessService.getGeographicBreakdown(req.query as any);
res.json(data);
} catch (err) {
next(err);
}
},
);
// GET /api/influence/effectiveness/funnel
router.get(
'/funnel',
validate(effectivenessQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await effectivenessService.getFunnelData(req.query as any);
res.json(data);
} catch (err) {
next(err);
}
},
);
// GET /api/influence/effectiveness/trends
router.get(
'/trends',
validate(trendQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await effectivenessService.getActivityTrends(req.query as any);
res.json(data);
} catch (err) {
next(err);
}
},
);
export { router as effectivenessRouter };

View File

@ -0,0 +1,26 @@
import { z } from 'zod';
export const effectivenessQuerySchema = z.object({
campaignId: z.string().optional(),
dateFrom: z.string().datetime({ offset: true }).optional(),
dateTo: z.string().datetime({ offset: true }).optional(),
});
export const trendQuerySchema = effectivenessQuerySchema.extend({
granularity: z.enum(['day', 'week']).default('day'),
});
export const geoQuerySchema = effectivenessQuerySchema.extend({
groupBy: z.enum(['province', 'city', 'postalCode']).default('postalCode'),
limit: z.coerce.number().int().positive().max(200).default(20),
});
export const repQuerySchema = effectivenessQuerySchema.extend({
sortBy: z.enum(['responseCount', 'responseRate', 'name']).default('responseCount'),
limit: z.coerce.number().int().positive().max(200).default(20),
});
export type EffectivenessQuery = z.infer<typeof effectivenessQuerySchema>;
export type TrendQuery = z.infer<typeof trendQuerySchema>;
export type GeoQuery = z.infer<typeof geoQuerySchema>;
export type RepQuery = z.infer<typeof repQuerySchema>;

View File

@ -0,0 +1,459 @@
import { Prisma, ResponseStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import type { EffectivenessQuery, TrendQuery, GeoQuery, RepQuery } from './effectiveness.schemas';
function buildDateFilter(query: EffectivenessQuery) {
const filter: { gte?: Date; lte?: Date } = {};
if (query.dateFrom) filter.gte = new Date(query.dateFrom);
if (query.dateTo) filter.lte = new Date(query.dateTo);
return Object.keys(filter).length > 0 ? filter : undefined;
}
export const effectivenessService = {
/**
* Per-campaign KPIs: email counts, response counts, response rate
*/
async getOverviewStats(query: EffectivenessQuery) {
const dateFilter = buildDateFilter(query);
const campaignWhere: Prisma.CampaignWhereInput = {};
if (query.campaignId) campaignWhere.id = query.campaignId;
const emailWhere: Prisma.CampaignEmailWhereInput = {};
if (query.campaignId) emailWhere.campaignId = query.campaignId;
if (dateFilter) emailWhere.sentAt = dateFilter;
const responseWhere: Prisma.RepresentativeResponseWhereInput = {};
if (query.campaignId) responseWhere.campaignId = query.campaignId;
if (dateFilter) responseWhere.createdAt = dateFilter;
const callWhere: Prisma.CallWhereInput = {};
if (query.campaignId) callWhere.campaignId = query.campaignId;
if (dateFilter) callWhere.calledAt = dateFilter;
const [campaigns, emailsByStatus, responsesByStatus, totalCalls, totalEmails, totalResponses] = await Promise.all([
prisma.campaign.findMany({
where: campaignWhere,
select: {
id: true,
title: true,
slug: true,
status: true,
createdAt: true,
_count: { select: { emails: true, responses: true, calls: true } },
},
orderBy: { createdAt: 'desc' },
}),
prisma.campaignEmail.groupBy({
by: ['campaignId', 'status'],
where: emailWhere,
_count: true,
}),
prisma.representativeResponse.groupBy({
by: ['campaignId', 'status'],
where: responseWhere,
_count: true,
}),
prisma.call.count({ where: callWhere }),
prisma.campaignEmail.count({ where: emailWhere }),
prisma.representativeResponse.count({ where: { ...responseWhere, status: ResponseStatus.APPROVED } }),
]);
// Build per-campaign email status map
const emailStatusMap = new Map<string, Record<string, number>>();
for (const row of emailsByStatus) {
if (!emailStatusMap.has(row.campaignId)) {
emailStatusMap.set(row.campaignId, {});
}
emailStatusMap.get(row.campaignId)![row.status] = row._count;
}
// Build per-campaign response status map
const responseStatusMap = new Map<string, Record<string, number>>();
for (const row of responsesByStatus) {
if (!responseStatusMap.has(row.campaignId)) {
responseStatusMap.set(row.campaignId, {});
}
responseStatusMap.get(row.campaignId)![row.status] = row._count;
}
const campaignStats = campaigns.map((c) => {
const emailBreakdown = emailStatusMap.get(c.id) || {};
const responseBreakdown = responseStatusMap.get(c.id) || {};
const emailTotal = Object.values(emailBreakdown).reduce((a, b) => a + b, 0);
const approvedResponses = responseBreakdown[ResponseStatus.APPROVED] || 0;
const responseRate = emailTotal > 0 ? approvedResponses / emailTotal : 0;
return {
campaignId: c.id,
title: c.title,
slug: c.slug,
status: c.status,
createdAt: c.createdAt,
emailTotal,
emailBreakdown,
responseTotal: Object.values(responseBreakdown).reduce((a, b) => a + b, 0),
approvedResponses,
responseBreakdown,
responseRate,
callCount: c._count.calls,
};
});
const activeCampaigns = campaigns.filter((c) => c.status === 'ACTIVE').length;
const avgResponseRate = totalEmails > 0 ? totalResponses / totalEmails : 0;
return {
summary: {
totalEmails,
totalResponses,
totalCalls,
activeCampaigns,
totalCampaigns: campaigns.length,
avgResponseRate,
},
campaigns: campaignStats,
};
},
/**
* Cross-campaign representative tracking: emails received, responses given, response rate
*/
async getRepresentativeEffectiveness(query: RepQuery) {
const dateFilter = buildDateFilter(query);
const emailWhere: Prisma.CampaignEmailWhereInput = {};
if (query.campaignId) emailWhere.campaignId = query.campaignId;
if (dateFilter) emailWhere.sentAt = dateFilter;
const responseWhere: Prisma.RepresentativeResponseWhereInput = {};
if (query.campaignId) responseWhere.campaignId = query.campaignId;
if (dateFilter) responseWhere.createdAt = dateFilter;
const [emailsByRecipient, responsesByRep] = await Promise.all([
prisma.campaignEmail.groupBy({
by: ['recipientEmail', 'recipientName', 'recipientLevel'],
where: emailWhere,
_count: true,
}),
prisma.representativeResponse.groupBy({
by: ['representativeName', 'representativeLevel'],
where: { ...responseWhere, status: ResponseStatus.APPROVED },
_count: true,
}),
]);
// Also count verified responses
const verifiedByRep = await prisma.representativeResponse.groupBy({
by: ['representativeName'],
where: { ...responseWhere, isVerified: true },
_count: true,
});
const verifiedMap = new Map(verifiedByRep.map((r) => [r.representativeName, r._count]));
// Build response map keyed by rep name (normalized lowercase)
const responseMap = new Map<string, { count: number; level: string }>();
for (const row of responsesByRep) {
responseMap.set(row.representativeName.toLowerCase(), {
count: row._count,
level: row.representativeLevel,
});
}
// Merge: start from email recipients, enrich with response data
const repMap = new Map<string, {
name: string;
email: string;
level: string | null;
emailsReceived: number;
responsesGiven: number;
verifiedCount: number;
responseRate: number;
}>();
for (const row of emailsByRecipient) {
const key = (row.recipientName || row.recipientEmail).toLowerCase();
const existing = repMap.get(key);
if (existing) {
existing.emailsReceived += row._count;
} else {
const respData = responseMap.get(key);
const verifiedCount = verifiedMap.get(row.recipientName || row.recipientEmail) || 0;
repMap.set(key, {
name: row.recipientName || row.recipientEmail,
email: row.recipientEmail,
level: row.recipientLevel,
emailsReceived: row._count,
responsesGiven: respData?.count || 0,
verifiedCount,
responseRate: 0,
});
}
}
// Also add reps who responded but weren't in email records
for (const row of responsesByRep) {
const key = row.representativeName.toLowerCase();
if (!repMap.has(key)) {
const verifiedCount = verifiedMap.get(row.representativeName) || 0;
repMap.set(key, {
name: row.representativeName,
email: '',
level: row.representativeLevel,
emailsReceived: 0,
responsesGiven: row._count,
verifiedCount,
responseRate: 0,
});
}
}
// Compute response rates
const reps = Array.from(repMap.values()).map((r) => ({
...r,
responseRate: r.emailsReceived > 0 ? r.responsesGiven / r.emailsReceived : 0,
}));
// Sort
if (query.sortBy === 'responseRate') {
reps.sort((a, b) => b.responseRate - a.responseRate);
} else if (query.sortBy === 'name') {
reps.sort((a, b) => a.name.localeCompare(b.name));
} else {
reps.sort((a, b) => b.responsesGiven - a.responsesGiven);
}
// Level distribution
const levelCounts: Record<string, number> = {};
for (const row of responsesByRep) {
levelCounts[row.representativeLevel] = (levelCounts[row.representativeLevel] || 0) + row._count;
}
return {
representatives: reps.slice(0, query.limit),
totalRepresentatives: reps.length,
levelDistribution: Object.entries(levelCounts).map(([level, count]) => ({ level, count })),
};
},
/**
* Engagement breakdown by geographic area (postal code, city, or province)
*/
async getGeographicBreakdown(query: GeoQuery) {
const dateFilter = buildDateFilter(query);
const emailWhere: Prisma.CampaignEmailWhereInput = {
userPostalCode: { not: null },
};
if (query.campaignId) emailWhere.campaignId = query.campaignId;
if (dateFilter) emailWhere.sentAt = dateFilter;
if (query.groupBy === 'postalCode') {
const results = await prisma.campaignEmail.groupBy({
by: ['userPostalCode'],
where: emailWhere,
_count: true,
orderBy: { _count: { userPostalCode: 'desc' } },
take: query.limit,
});
// Enrich with city/province from postal code cache
const postalCodes = results
.map((r) => r.userPostalCode)
.filter((pc): pc is string => pc !== null);
const cacheEntries = postalCodes.length > 0
? await prisma.postalCodeCache.findMany({
where: { postalCode: { in: postalCodes } },
select: { postalCode: true, city: true, province: true },
})
: [];
const cacheMap = new Map(cacheEntries.map((e) => [e.postalCode, e]));
return {
groupBy: query.groupBy,
data: results.map((r) => {
const cache = cacheMap.get(r.userPostalCode || '');
return {
key: r.userPostalCode || 'Unknown',
emailCount: r._count,
city: cache?.city || null,
province: cache?.province || null,
};
}),
};
}
// For city/province grouping, we need to join with postal_code_cache
const groupCol = query.groupBy === 'province' ? 'pcc.province' : 'pcc.city';
const dateClause = dateFilter
? `AND ce."sentAt" ${dateFilter.gte ? `>= '${dateFilter.gte.toISOString()}'` : ''} ${dateFilter.lte ? `AND ce."sentAt" <= '${dateFilter.lte.toISOString()}'` : ''}`
: '';
const campaignClause = query.campaignId
? `AND ce."campaignId" = '${query.campaignId}'`
: '';
const rawResults = await prisma.$queryRawUnsafe<Array<{ key: string; email_count: bigint }>>(
`SELECT ${groupCol} as key, COUNT(*) as email_count
FROM campaign_emails ce
LEFT JOIN postal_code_cache pcc ON ce."userPostalCode" = pcc."postalCode"
WHERE ce."userPostalCode" IS NOT NULL
AND ${groupCol} IS NOT NULL
${campaignClause}
${dateClause}
GROUP BY ${groupCol}
ORDER BY email_count DESC
LIMIT $1`,
query.limit,
);
return {
groupBy: query.groupBy,
data: rawResults.map((r) => ({
key: r.key,
emailCount: Number(r.email_count),
city: null,
province: null,
})),
};
},
/**
* Conversion funnel: emails unique participants responses verified responses
*/
async getFunnelData(query: EffectivenessQuery) {
const dateFilter = buildDateFilter(query);
const emailWhere: Prisma.CampaignEmailWhereInput = {};
if (query.campaignId) emailWhere.campaignId = query.campaignId;
if (dateFilter) emailWhere.sentAt = dateFilter;
const responseWhere: Prisma.RepresentativeResponseWhereInput = {};
if (query.campaignId) responseWhere.campaignId = query.campaignId;
if (dateFilter) responseWhere.createdAt = dateFilter;
const callWhere: Prisma.CallWhereInput = {};
if (query.campaignId) callWhere.campaignId = query.campaignId;
if (dateFilter) callWhere.calledAt = dateFilter;
// Build date clause for raw SQL
const dateClauseParts: string[] = [];
if (query.campaignId) dateClauseParts.push(`"campaignId" = '${query.campaignId}'`);
if (dateFilter?.gte) dateClauseParts.push(`"sentAt" >= '${dateFilter.gte.toISOString()}'`);
if (dateFilter?.lte) dateClauseParts.push(`"sentAt" <= '${dateFilter.lte.toISOString()}'`);
const rawWhereClause = dateClauseParts.length > 0
? `WHERE ${dateClauseParts.join(' AND ')}`
: '';
const [emailsSent, uniqueParticipants, approvedResponses, verifiedResponses, callsMade] = await Promise.all([
prisma.campaignEmail.count({ where: emailWhere }),
prisma.$queryRawUnsafe<[{ count: bigint }]>(
`SELECT COUNT(DISTINCT "userEmail") as count FROM campaign_emails ${rawWhereClause}`,
),
prisma.representativeResponse.count({
where: { ...responseWhere, status: ResponseStatus.APPROVED },
}),
prisma.representativeResponse.count({
where: { ...responseWhere, isVerified: true },
}),
prisma.call.count({ where: callWhere }),
]);
const participantCount = Number(uniqueParticipants[0]?.count || 0);
const stages = [
{ name: 'Emails Sent', count: emailsSent },
{ name: 'Unique Participants', count: participantCount },
{ name: 'Responses Received', count: approvedResponses },
{ name: 'Verified Responses', count: verifiedResponses },
{ name: 'Calls Made', count: callsMade },
];
// Compute percentages relative to first stage and dropoff from previous
const firstCount = stages[0].count || 1;
return stages.map((stage, i) => ({
...stage,
percentOfFirst: stage.count / firstCount,
dropoff: i > 0
? (stages[i - 1].count > 0
? 1 - stage.count / stages[i - 1].count
: 0)
: 0,
}));
},
/**
* Time-series: daily/weekly email + response volumes
*/
async getActivityTrends(query: TrendQuery) {
const dateFilter = buildDateFilter(query);
const truncFn = query.granularity === 'week' ? 'week' : 'day';
// Default: last 30 days
const defaultFrom = new Date();
defaultFrom.setDate(defaultFrom.getDate() - 30);
const from = dateFilter?.gte || defaultFrom;
const to = dateFilter?.lte || new Date();
const campaignClause = query.campaignId
? `AND "campaignId" = '${query.campaignId}'`
: '';
const [emailTrends, responseTrends] = await Promise.all([
prisma.$queryRawUnsafe<Array<{ period: Date; count: bigint }>>(
`SELECT DATE_TRUNC('${truncFn}', "sentAt") as period, COUNT(*) as count
FROM campaign_emails
WHERE "sentAt" >= $1 AND "sentAt" <= $2
${campaignClause}
GROUP BY period
ORDER BY period ASC`,
from,
to,
),
prisma.$queryRawUnsafe<Array<{ period: Date; count: bigint }>>(
`SELECT DATE_TRUNC('${truncFn}', "createdAt") as period, COUNT(*) as count
FROM representative_responses
WHERE "createdAt" >= $1 AND "createdAt" <= $2
AND status = 'APPROVED'
${campaignClause}
GROUP BY period
ORDER BY period ASC`,
from,
to,
),
]);
// Merge into a single series with both email and response counts
const periodMap = new Map<string, { emails: number; responses: number }>();
for (const row of emailTrends) {
const key = row.period.toISOString().split('T')[0];
if (!periodMap.has(key)) periodMap.set(key, { emails: 0, responses: 0 });
periodMap.get(key)!.emails = Number(row.count);
}
for (const row of responseTrends) {
const key = row.period.toISOString().split('T')[0];
if (!periodMap.has(key)) periodMap.set(key, { emails: 0, responses: 0 });
periodMap.get(key)!.responses = Number(row.count);
}
// Sort by date and return
const series = Array.from(periodMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, counts]) => ({
date,
emails: counts.emails,
responses: counts.responses,
}));
return {
granularity: query.granularity,
dateFrom: from.toISOString(),
dateTo: to.toISOString(),
series,
};
},
};

View File

@ -13,6 +13,7 @@ export const updateMapSettingsSchema = z.object({
qrCode2Label: z.string().nullable().optional(),
qrCode3Url: z.string().url().nullable().optional().or(z.literal('')),
qrCode3Label: z.string().nullable().optional(),
publicMapEnabled: z.boolean().optional(),
});
export type UpdateMapSettingsInput = z.infer<typeof updateMapSettingsSchema>;

View File

@ -78,6 +78,7 @@ export async function publicRoutes(fastify: FastifyInstance) {
publishedAt: true,
category: true,
isLocked: true,
accessLevel: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
@ -262,6 +263,7 @@ export async function publicRoutes(fastify: FastifyInstance) {
select: {
path: true,
filename: true,
accessLevel: true,
},
});
@ -269,6 +271,40 @@ export async function publicRoutes(fastify: FastifyInstance) {
return reply.code(404).send({ message: 'Video not found or not published' });
}
// Content gating: check access level against user subscription
if (video.accessLevel && video.accessLevel !== 'free') {
const userId = (request as any).user?.id;
if (!userId) {
return reply.code(403).send({
message: 'This content requires a subscription',
accessLevel: video.accessLevel,
requiresAuth: true,
});
}
const subscription = await prisma.userSubscription.findFirst({
where: {
userId,
status: 'active',
},
include: { plan: true },
});
if (!subscription) {
return reply.code(403).send({
message: 'This content requires an active subscription',
accessLevel: video.accessLevel,
requiresSubscription: true,
});
}
// Premium content requires tier >= 2
if (video.accessLevel === 'premium' && (subscription.plan?.tier ?? 0) < 2) {
return reply.code(403).send({
message: 'This content requires a premium subscription',
accessLevel: video.accessLevel,
requiresUpgrade: true,
});
}
}
// Validate path doesn't contain traversal attempts
if (video.path.includes('..') || video.filename.includes('..')) {
logger.warn(`Path traversal attempt detected: ${video.path}/${video.filename}`);

View File

@ -18,6 +18,7 @@ const UpdateVideoSchema = z.object({
quality: z.string().max(50).nullable().optional(),
position: z.number().int().min(0).nullable().optional(),
isShort: z.boolean().optional(),
accessLevel: z.enum(['free', 'member', 'premium']).optional(),
});
export async function videoActionsRoutes(fastify: FastifyInstance) {
@ -55,6 +56,7 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
if (updates.quality !== undefined) data.quality = updates.quality;
if (updates.position !== undefined) data.position = updates.position;
if (updates.isShort !== undefined) data.isShort = updates.isShort;
if (updates.accessLevel !== undefined) data.accessLevel = updates.accessLevel;
const updatedVideo = await prisma.video.update({
where: { id: videoId },
@ -388,4 +390,47 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
}
}
);
/**
* POST /videos/bulk-access-level
* Set access level on multiple videos at once
*/
fastify.post<{
Body: { videoIds: number[]; accessLevel: string };
}>(
'/bulk-access-level',
{
preHandler: requireAdminRole,
},
async (request, reply) => {
const schema = z.object({
videoIds: z.array(z.number().int()).min(1).max(500),
accessLevel: z.enum(['free', 'member', 'premium']),
});
const parseResult = schema.safeParse(request.body);
if (!parseResult.success) {
return reply.code(400).send({ message: 'Invalid input', errors: parseResult.error.errors });
}
const { videoIds, accessLevel } = parseResult.data;
try {
const result = await prisma.video.updateMany({
where: { id: { in: videoIds } },
data: { accessLevel },
});
logger.info(`Bulk updated access level to "${accessLevel}" for ${result.count} videos`, { videoIds });
return {
success: true,
updatedCount: result.count,
};
} catch (error) {
logger.error('Failed to bulk update access level', { error, videoIds });
return reply.code(500).send({ message: 'Failed to update access levels' });
}
}
);
}

View File

@ -77,6 +77,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
scheduledUnpublishAt: true,
category: true,
isShort: true,
accessLevel: true,
},
orderBy: {
createdAt: 'desc',

View File

@ -216,6 +216,12 @@ async function exportToMkDocs(opts: ExportOptions): Promise<string> {
content = content.replace(/href="\/gallery\?expanded=/g, `href="${adminUrl}/gallery?expanded=`);
content = content.replace(/src="http:\/\/localhost:4100\//g, `src="${adminUrl.replace(/:\d+$/, ':4100')}/`);
// Rewrite payment page URLs to absolute for MkDocs context
content = content.replace(/href="\/donate"/g, `href="${adminUrl}/donate"`);
content = content.replace(/href="\/pricing"/g, `href="${adminUrl}/pricing"`);
content = content.replace(/href="\/shop"/g, `href="${adminUrl}/shop"`);
content = content.replace(/href="\/payments\/success"/g, `href="${adminUrl}/payments/success"`);
await fs.writeFile(filePath, content, 'utf-8');
logger.info(`Exported landing page to MkDocs: ${mkdocsPath} (${editorMode}/${exportMode})`);

View File

@ -0,0 +1,180 @@
import { prisma } from '../../config/database';
import { getStripe } from '../../services/stripe.client';
import { env } from '../../config/env';
import { paymentSettingsService } from './payment-settings.service';
import { stringify } from 'csv-stringify/sync';
import { logger } from '../../utils/logger';
export const donationsService = {
/** Create a Stripe Checkout session for a donation */
async createDonationCheckout(
amountCents: number,
email: string,
name?: string,
message?: string,
isAnonymous?: boolean,
) {
const settings = await paymentSettingsService.get();
if (!settings.enableDonations) throw new Error('Donations are currently disabled');
if (amountCents < settings.donationMinimum) {
throw new Error(`Minimum donation is $${(settings.donationMinimum / 100).toFixed(2)}`);
}
const stripe = await getStripe();
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: settings.defaultCurrency || 'cad',
product_data: {
name: 'Donation',
description: settings.donationPageTitle || 'Support Our Work',
},
unit_amount: amountCents,
},
quantity: 1,
}],
customer_email: email,
success_url: `${env.ADMIN_URL}/payments/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.ADMIN_URL}/donate`,
metadata: {
type: 'donation',
email,
name: name || '',
message: message || '',
isAnonymous: isAnonymous ? 'true' : 'false',
},
});
// Create pending order
await prisma.order.create({
data: {
amountCAD: amountCents,
status: 'PENDING',
stripeCheckoutSessionId: session.id,
type: 'donation',
buyerEmail: email,
buyerName: name || null,
donorMessage: message || null,
isAnonymous: isAnonymous || false,
},
});
return { sessionId: session.id, url: session.url };
},
/** List donations (admin) */
async listDonations(filters: { page: number; limit: number; search?: string }) {
const { page, limit, search } = filters;
const where: Record<string, unknown> = { type: 'donation' };
if (search) {
(where as Record<string, unknown>).OR = [
{ buyerEmail: { contains: search, mode: 'insensitive' } },
{ buyerName: { contains: search, mode: 'insensitive' } },
];
}
const [orders, total] = await Promise.all([
prisma.order.findMany({
where: where as import('@prisma/client').Prisma.OrderWhereInput,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.order.count({ where: where as import('@prisma/client').Prisma.OrderWhereInput }),
]);
return {
donations: orders,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** Refund a donation via Stripe */
async refundDonation(orderId: string, reason?: string) {
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order) throw new Error('Donation not found');
if (order.type !== 'donation') throw new Error('Order is not a donation');
if (order.status !== 'COMPLETED') throw new Error('Only completed donations can be refunded');
if (!order.stripePaymentIntentId) throw new Error('No Stripe payment intent found for this donation');
const stripe = await getStripe();
await stripe.refunds.create({
payment_intent: order.stripePaymentIntentId,
reason: 'requested_by_customer',
metadata: {
admin_reason: reason || 'Admin-initiated refund',
order_id: orderId,
},
});
const updated = await prisma.order.update({
where: { id: orderId },
data: { status: 'REFUNDED' },
});
logger.info(`Donation refunded: ${orderId}, $${(order.amountCAD / 100).toFixed(2)}`, {
orderId,
reason: reason || 'No reason provided',
});
return updated;
},
/** Export donations to CSV */
async exportToCsv(filters: { search?: string; status?: string }) {
const where: Record<string, unknown> = { type: 'donation' };
if (filters.status) {
(where as Record<string, unknown>).status = filters.status;
}
if (filters.search) {
(where as Record<string, unknown>).OR = [
{ buyerEmail: { contains: filters.search, mode: 'insensitive' } },
{ buyerName: { contains: filters.search, mode: 'insensitive' } },
];
}
const orders = await prisma.order.findMany({
where: where as import('@prisma/client').Prisma.OrderWhereInput,
orderBy: { createdAt: 'desc' },
});
return stringify(orders.map((o) => ({
'Date': o.createdAt.toISOString(),
'Donor Name': o.isAnonymous ? 'Anonymous' : (o.buyerName || ''),
'Donor Email': o.isAnonymous ? '' : (o.buyerEmail || ''),
'Amount (CAD)': (o.amountCAD / 100).toFixed(2),
'Status': o.status,
'Message': o.donorMessage || '',
'Anonymous': o.isAnonymous ? 'Yes' : 'No',
'Stripe Payment Intent': o.stripePaymentIntentId || '',
'Stripe Checkout Session': o.stripeCheckoutSessionId || '',
'Completed At': o.completedAt ? o.completedAt.toISOString() : '',
'Order ID': o.id,
})), { header: true });
},
/** Get donation stats */
async getDonationStats() {
const [totalDonations, totalAmount, recentDonations] = await Promise.all([
prisma.order.count({ where: { type: 'donation', status: 'COMPLETED' } }),
prisma.order.aggregate({
where: { type: 'donation', status: 'COMPLETED' },
_sum: { amountCAD: true },
}),
prisma.order.findMany({
where: { type: 'donation', status: 'COMPLETED' },
orderBy: { createdAt: 'desc' },
take: 5,
}),
]);
return {
totalDonations,
totalAmount: totalAmount._sum.amountCAD || 0,
recentDonations,
};
},
};

View File

@ -0,0 +1,168 @@
import { emailService } from '../../services/email.service';
import { siteSettingsService } from '../settings/settings.service';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
export const paymentEmailService = {
/** Send donation receipt after successful checkout */
async sendDonationReceipt(order: {
id: string;
buyerEmail: string;
buyerName: string | null;
amountCAD: number;
donorMessage: string | null;
isAnonymous: boolean;
completedAt: Date | null;
}): Promise<void> {
try {
const orgName = await this.getOrgName();
const vars: Record<string, string> = {
RECIPIENT_NAME: order.buyerName || 'Supporter',
AMOUNT: `$${(order.amountCAD / 100).toFixed(2)}`,
ORDER_ID: order.id,
DONATION_DATE: (order.completedAt || new Date()).toLocaleDateString('en-CA', {
year: 'numeric', month: 'long', day: 'numeric',
}),
DONOR_MESSAGE: order.donorMessage || '',
IS_ANONYMOUS: order.isAnonymous ? 'true' : '',
ORGANIZATION_NAME: orgName,
};
const dbTemplate = await emailService['loadTemplateFromDatabase']('donation-receipt');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await emailService.processTemplate(dbTemplate.html, vars);
text = await emailService.processTextTemplate(dbTemplate.text, vars);
subject = emailService.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = emailService.loadTemplate('donation-receipt', 'html');
const txtTemplate = emailService.loadTemplate('donation-receipt', 'txt');
html = await emailService.processTemplate(htmlTemplate, vars);
text = await emailService.processTextTemplate(txtTemplate, vars);
subject = `Donation Receipt — ${orgName}`;
}
await emailService.sendEmail({ to: order.buyerEmail, subject, html, text });
logger.info(`Donation receipt sent to ${order.buyerEmail} for order ${order.id}`);
} catch (err) {
logger.error('Failed to send donation receipt email:', err);
}
},
/** Send product purchase receipt after successful checkout */
async sendProductReceipt(order: {
id: string;
buyerEmail: string;
buyerName: string | null;
amountCAD: number;
completedAt: Date | null;
product: { title: string; type: string } | null;
}): Promise<void> {
try {
const orgName = await this.getOrgName();
const vars: Record<string, string> = {
RECIPIENT_NAME: order.buyerName || 'Customer',
AMOUNT: `$${(order.amountCAD / 100).toFixed(2)}`,
ORDER_ID: order.id,
PRODUCT_TITLE: order.product?.title || 'Product',
PRODUCT_TYPE: order.product?.type || 'DIGITAL',
PURCHASE_DATE: (order.completedAt || new Date()).toLocaleDateString('en-CA', {
year: 'numeric', month: 'long', day: 'numeric',
}),
ORGANIZATION_NAME: orgName,
};
const dbTemplate = await emailService['loadTemplateFromDatabase']('product-receipt');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await emailService.processTemplate(dbTemplate.html, vars);
text = await emailService.processTextTemplate(dbTemplate.text, vars);
subject = emailService.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = emailService.loadTemplate('product-receipt', 'html');
const txtTemplate = emailService.loadTemplate('product-receipt', 'txt');
html = await emailService.processTemplate(htmlTemplate, vars);
text = await emailService.processTextTemplate(txtTemplate, vars);
subject = `Purchase Receipt — ${orgName}`;
}
await emailService.sendEmail({ to: order.buyerEmail, subject, html, text });
logger.info(`Product receipt sent to ${order.buyerEmail} for order ${order.id}`);
} catch (err) {
logger.error('Failed to send product receipt email:', err);
}
},
/** Send subscription welcome email after checkout */
async sendSubscriptionWelcome(opts: {
userId: string;
planId: number;
stripeSubscriptionId: string;
currentPeriodEnd: Date;
}): Promise<void> {
try {
const { prisma } = await import('../../config/database');
const [user, plan] = await Promise.all([
prisma.user.findUnique({ where: { id: opts.userId } }),
prisma.subscriptionPlan.findUnique({ where: { id: opts.planId } }),
]);
if (!user || !plan) {
logger.warn('Cannot send subscription welcome: user or plan not found', {
userId: opts.userId, planId: opts.planId,
});
return;
}
const orgName = await this.getOrgName();
const loginUrl = `${env.ADMIN_URL || 'http://localhost:3000'}/login`;
const frequency = plan.yearlyPriceCAD && plan.yearlyPriceCAD > 0
? 'per month or per year'
: 'per month';
const vars: Record<string, string> = {
RECIPIENT_NAME: user.name || user.email,
PLAN_NAME: plan.name,
AMOUNT: `$${(plan.priceCAD / 100).toFixed(2)}`,
FREQUENCY: frequency,
RENEWAL_DATE: opts.currentPeriodEnd.toLocaleDateString('en-CA', {
year: 'numeric', month: 'long', day: 'numeric',
}),
SUBSCRIPTION_ID: opts.stripeSubscriptionId,
LOGIN_URL: loginUrl,
ORGANIZATION_NAME: orgName,
};
const dbTemplate = await emailService['loadTemplateFromDatabase']('subscription-welcome');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await emailService.processTemplate(dbTemplate.html, vars);
text = await emailService.processTextTemplate(dbTemplate.text, vars);
subject = emailService.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = emailService.loadTemplate('subscription-welcome', 'html');
const txtTemplate = emailService.loadTemplate('subscription-welcome', 'txt');
html = await emailService.processTemplate(htmlTemplate, vars);
text = await emailService.processTextTemplate(txtTemplate, vars);
subject = `Welcome to ${plan.name}${orgName}`;
}
await emailService.sendEmail({ to: user.email, subject, html, text });
logger.info(`Subscription welcome sent to ${user.email} for plan ${plan.name}`);
} catch (err) {
logger.error('Failed to send subscription welcome email:', err);
}
},
async getOrgName(): Promise<string> {
try {
const settings = await siteSettingsService.get();
return settings.organizationName || 'Changemaker Lite';
} catch {
return 'Changemaker Lite';
}
},
};

View File

@ -0,0 +1,71 @@
import { prisma } from '../../config/database';
import type { PaymentSettings } from '@prisma/client';
import type { UpdatePaymentSettingsInput } from './payments.schemas';
import { encrypt, decrypt } from '../../utils/crypto';
import { resetStripeClient } from '../../services/stripe.client';
const ENCRYPTED_FIELDS = ['stripeSecretKey', 'stripeWebhookSecret'] as const;
const SENSITIVE_FIELDS = ['stripeSecretKey', 'stripeWebhookSecret'] as const;
function decryptSettings(settings: PaymentSettings): PaymentSettings {
for (const field of ENCRYPTED_FIELDS) {
const value = settings[field];
if (typeof value === 'string' && value) {
(settings as Record<string, unknown>)[field] = decrypt(value);
}
}
return settings;
}
export const paymentSettingsService = {
/** Full settings with decrypted secrets (admin use) */
async get(): Promise<PaymentSettings> {
let settings = await prisma.paymentSettings.findFirst();
if (!settings) {
settings = await prisma.paymentSettings.create({ data: {} });
}
return decryptSettings(settings);
},
/** Public-safe settings (strips secret keys) */
async getPublic() {
const settings = await this.get();
const result = { ...settings } as Record<string, unknown>;
for (const field of SENSITIVE_FIELDS) {
delete result[field];
}
return result;
},
async update(data: UpdatePaymentSettingsInput): Promise<PaymentSettings> {
const toWrite = { ...data } as Record<string, unknown>;
// Encrypt sensitive fields
for (const field of ENCRYPTED_FIELDS) {
if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) {
toWrite[field] = encrypt(toWrite[field] as string);
}
}
// Handle donationSuggestedAmounts as JSON
if (data.donationSuggestedAmounts) {
toWrite.donationSuggestedAmounts = JSON.stringify(data.donationSuggestedAmounts);
}
const existing = await prisma.paymentSettings.findFirst();
let settings: PaymentSettings;
if (existing) {
settings = await prisma.paymentSettings.update({
where: { id: existing.id },
data: toWrite,
});
} else {
settings = await prisma.paymentSettings.create({ data: toWrite });
}
// Reset Stripe client so it picks up new keys
resetStripeClient();
return decryptSettings(settings);
},
};

View File

@ -0,0 +1,369 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { paymentSettingsService } from './payment-settings.service';
import { subscriptionsService } from './subscriptions.service';
import { productsService } from './products.service';
import { donationsService } from './donations.service';
import {
updatePaymentSettingsSchema,
createPlanSchema,
updatePlanSchema,
createProductSchema,
updateProductSchema,
subscriptionFiltersSchema,
orderFiltersSchema,
refundDonationSchema,
} from './payments.schemas';
const router = Router();
// All admin routes require SUPER_ADMIN
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN));
// =================== Settings ===================
// GET /api/payments/admin/settings
router.get('/settings', async (_req: Request, res: Response, next: NextFunction) => {
try {
const settings = await paymentSettingsService.get();
// Mask secret key for display
const masked = {
...settings,
stripeSecretKey: settings.stripeSecretKey ? '••••' + settings.stripeSecretKey.slice(-4) : '',
stripeWebhookSecret: settings.stripeWebhookSecret ? '••••' + settings.stripeWebhookSecret.slice(-4) : '',
};
res.json(masked);
} catch (err) {
next(err);
}
});
// PUT /api/payments/admin/settings
router.put(
'/settings',
validate(updatePaymentSettingsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const settings = await paymentSettingsService.update(req.body);
res.json(settings);
} catch (err) {
next(err);
}
}
);
// POST /api/payments/admin/settings/test-connection
router.post('/settings/test-connection', async (_req: Request, res: Response, next: NextFunction) => {
try {
const { getStripe } = await import('../../services/stripe.client');
const stripe = await getStripe();
// Simple test: list 1 product
await stripe.products.list({ limit: 1 });
res.json({ success: true, message: 'Stripe connection verified' });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Connection failed';
res.json({ success: false, message });
}
});
// =================== Dashboard ===================
// GET /api/payments/admin/dashboard
router.get('/dashboard', async (_req: Request, res: Response, next: NextFunction) => {
try {
const [subStats, donationStats] = await Promise.all([
subscriptionsService.getDashboardStats(),
donationsService.getDonationStats(),
]);
res.json({
...subStats,
donations: donationStats,
});
} catch (err) {
next(err);
}
});
// =================== Plans ===================
// GET /api/payments/admin/plans
router.get('/plans', async (_req: Request, res: Response, next: NextFunction) => {
try {
const { prisma } = await import('../../config/database');
const plans = await prisma.subscriptionPlan.findMany({ orderBy: { displayOrder: 'asc' } });
res.json(plans);
} catch (err) {
next(err);
}
});
// POST /api/payments/admin/plans
router.post(
'/plans',
validate(createPlanSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const plan = await subscriptionsService.createPlan(req.body);
res.status(201).json(plan);
} catch (err) {
next(err);
}
}
);
// PUT /api/payments/admin/plans/:id
router.put(
'/plans/:id',
validate(updatePlanSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
const plan = await subscriptionsService.updatePlan(id, req.body);
res.json(plan);
} catch (err) {
next(err);
}
}
);
// DELETE /api/payments/admin/plans/:id
router.delete('/plans/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
await subscriptionsService.deletePlan(id);
res.json({ success: true });
} catch (err) {
next(err);
}
});
// POST /api/payments/admin/plans/:id/sync-stripe
router.post('/plans/:id/sync-stripe', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
const plan = await subscriptionsService.syncPlanToStripe(id);
res.json(plan);
} catch (err) {
next(err);
}
});
// =================== Subscriptions ===================
// GET /api/payments/admin/subscriptions/export
router.get('/subscriptions/export', async (req: Request, res: Response, next: NextFunction) => {
try {
const search = req.query.search as string | undefined;
const status = req.query.status as import('@prisma/client').SubscriptionStatus | undefined;
const planId = req.query.planId ? parseInt(req.query.planId as string, 10) : undefined;
const csv = await subscriptionsService.exportToCsv({ search, status, planId });
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=subscriptions-export.csv');
res.send(csv);
} catch (err) {
next(err);
}
});
// GET /api/payments/admin/subscriptions
router.get(
'/subscriptions',
validate(subscriptionFiltersSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await subscriptionsService.listSubscriptions(req.query as Record<string, unknown> as Parameters<typeof subscriptionsService.listSubscriptions>[0]);
res.json(result);
} catch (err) {
next(err);
}
}
);
// POST /api/payments/admin/subscriptions/:id/cancel
router.post('/subscriptions/:id/cancel', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
const immediate = req.body?.immediate === true;
const sub = await subscriptionsService.cancelSubscription(id, immediate);
res.json(sub);
} catch (err) {
next(err);
}
});
// =================== Products ===================
// GET /api/payments/admin/products
router.get('/products', async (req: Request, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string, 10) || 1;
const limit = parseInt(req.query.limit as string, 10) || 20;
const type = req.query.type as string | undefined;
const search = req.query.search as string | undefined;
const result = await productsService.listAll({ page, limit, type, search });
res.json(result);
} catch (err) {
next(err);
}
});
// POST /api/payments/admin/products
router.post(
'/products',
validate(createProductSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const product = await productsService.create({
...req.body,
createdByUserId: req.user!.id,
});
res.status(201).json(product);
} catch (err) {
next(err);
}
}
);
// PUT /api/payments/admin/products/:id
router.put(
'/products/:id',
validate(updateProductSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const product = await productsService.update(req.params.id as string, req.body);
res.json(product);
} catch (err) {
next(err);
}
}
);
// DELETE /api/payments/admin/products/:id
router.delete('/products/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await productsService.delete(req.params.id as string);
res.json({ success: true });
} catch (err) {
next(err);
}
});
// POST /api/payments/admin/products/:id/sync-stripe
router.post('/products/:id/sync-stripe', async (req: Request, res: Response, next: NextFunction) => {
try {
const product = await productsService.syncProductToStripe(req.params.id as string);
res.json(product);
} catch (err) {
next(err);
}
});
// =================== Orders ===================
// GET /api/payments/admin/orders
router.get(
'/orders',
validate(orderFiltersSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await productsService.listOrders(req.query as Record<string, unknown> as Parameters<typeof productsService.listOrders>[0]);
res.json(result);
} catch (err) {
next(err);
}
}
);
// POST /api/payments/admin/orders/:id/refund
router.post('/orders/:id/refund', async (req: Request, res: Response, next: NextFunction) => {
try {
const order = await productsService.refundOrder(req.params.id as string);
res.json(order);
} catch (err) {
next(err);
}
});
// =================== Donations ===================
// GET /api/payments/admin/donations/export
router.get('/donations/export', async (req: Request, res: Response, next: NextFunction) => {
try {
const search = req.query.search as string | undefined;
const status = req.query.status as string | undefined;
const csv = await donationsService.exportToCsv({ search, status });
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=donations-export.csv');
res.send(csv);
} catch (err) {
next(err);
}
});
// GET /api/payments/admin/donations
router.get('/donations', async (req: Request, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string, 10) || 1;
const limit = parseInt(req.query.limit as string, 10) || 20;
const search = req.query.search as string | undefined;
const result = await donationsService.listDonations({ page, limit, search });
res.json(result);
} catch (err) {
next(err);
}
});
// POST /api/payments/admin/donations/:id/refund
router.post(
'/donations/:id/refund',
validate(refundDonationSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const order = await donationsService.refundDonation(
req.params.id as string,
req.body.reason,
);
res.json(order);
} catch (err) {
next(err);
}
}
);
// =================== CSV Export ===================
// GET /api/payments/admin/export
router.get('/export', async (_req: Request, res: Response, next: NextFunction) => {
try {
const { prisma } = await import('../../config/database');
const orders = await prisma.order.findMany({
where: { status: 'COMPLETED' },
include: { product: { select: { title: true } } },
orderBy: { createdAt: 'desc' },
});
const lines = ['Date,Type,Amount (CAD),Buyer Email,Buyer Name,Product,Status'];
for (const o of orders) {
lines.push([
o.createdAt.toISOString(),
o.type,
(o.amountCAD / 100).toFixed(2),
`"${o.buyerEmail}"`,
`"${o.buyerName || ''}"`,
`"${o.product?.title || ''}"`,
o.status,
].join(','));
}
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=payments-export.csv');
res.send(lines.join('\n'));
} catch (err) {
next(err);
}
});
export { router as paymentsAdminRouter };

View File

@ -0,0 +1,149 @@
import { Router, Request, Response, NextFunction } from 'express';
import { getPublishableKey } from '../../services/stripe.client';
import { paymentSettingsService } from './payment-settings.service';
import { subscriptionsService } from './subscriptions.service';
import { productsService } from './products.service';
import { donationsService } from './donations.service';
import { authenticate } from '../../middleware/auth.middleware';
import { validate } from '../../middleware/validate';
import {
createSubscriptionCheckoutSchema,
createProductCheckoutSchema,
createDonationCheckoutSchema,
} from './payments.schemas';
const router = Router();
// GET /api/payments/config — public payment config (publishable key, donation settings)
router.get('/config', async (_req: Request, res: Response, next: NextFunction) => {
try {
const publishableKey = await getPublishableKey();
const settings = await paymentSettingsService.getPublic();
res.json({
publishableKey,
defaultCurrency: settings.defaultCurrency,
enableDonations: settings.enableDonations,
donationSuggestedAmounts: settings.donationSuggestedAmounts,
donationMinimum: settings.donationMinimum,
donationPageTitle: settings.donationPageTitle,
donationPageDescription: settings.donationPageDescription,
thankYouMessage: settings.thankYouMessage,
});
} catch (err) {
next(err);
}
});
// GET /api/payments/plans — list active subscription plans
router.get('/plans', async (_req: Request, res: Response, next: NextFunction) => {
try {
const plans = await subscriptionsService.listActivePlans();
res.json(plans);
} catch (err) {
next(err);
}
});
// GET /api/payments/products — list active products
router.get('/products', async (req: Request, res: Response, next: NextFunction) => {
try {
const type = req.query.type as string | undefined;
const products = await productsService.listActive(type);
res.json(products);
} catch (err) {
next(err);
}
});
// POST /api/payments/subscribe — create subscription checkout (requires login)
router.post(
'/subscribe',
authenticate,
validate(createSubscriptionCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { planId, frequency } = req.body;
const result = await subscriptionsService.createCheckoutSession(
req.user!.id,
planId,
frequency,
);
res.json(result);
} catch (err) {
next(err);
}
}
);
// POST /api/payments/purchase — create product checkout (guest or logged-in)
router.post(
'/purchase',
validate(createProductCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { productId, buyerEmail, buyerName } = req.body;
// Try to get user ID from optional auth
const userId = req.user?.id;
const result = await productsService.createProductCheckout(productId, buyerEmail, buyerName, userId);
res.json(result);
} catch (err) {
next(err);
}
}
);
// POST /api/payments/donate — create donation checkout (no auth required)
router.post(
'/donate',
validate(createDonationCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { amountCents, email, name, message, isAnonymous } = req.body;
const result = await donationsService.createDonationCheckout(
amountCents,
email,
name,
message,
isAnonymous,
);
res.json(result);
} catch (err) {
next(err);
}
}
);
// GET /api/payments/my-subscription — current user's subscription
router.get(
'/my-subscription',
authenticate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const sub = await subscriptionsService.getActiveSubscription(req.user!.id);
res.json(sub || { status: 'none' });
} catch (err) {
next(err);
}
}
);
// POST /api/payments/my-subscription/cancel — cancel own subscription
router.post(
'/my-subscription/cancel',
authenticate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const sub = await subscriptionsService.getActiveSubscription(req.user!.id);
if (!sub) {
res.status(404).json({ error: { message: 'No active subscription', code: 'NOT_FOUND' } });
return;
}
const updated = await subscriptionsService.cancelSubscription(sub.id);
res.json(updated);
} catch (err) {
next(err);
}
}
);
export { router as paymentsPublicRouter };

Some files were not shown because too many files have changed in this diff Show More