diff --git a/.env.example b/.env.example index 821a7ffc..1d6a418e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 25021100..69c8d35e 100644 --- a/README.md +++ b/README.md @@ -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 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 -``` \ No newline at end of file +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. diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 84fc6d49..1042cfd7 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -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 ; + if (!isAuthenticated) return ; if (user && isAdmin(user)) return ; return ; } @@ -167,6 +179,20 @@ export default function App() { } /> } /> + {/* Public Payment pages (PublicLayout, dark blue theme) — feature-gated */} + }> + } /> + + }> + } /> + + }> + } /> + + }> + } /> + + {/* Public Media Gallery (purple theme) — feature-gated */} }> } /> @@ -285,6 +311,14 @@ export default function App() { } /> + + + + } + /> } /> + + + + } + /> } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> } /> diff --git a/admin/src/components/AppLayout.tsx b/admin/src/components/AppLayout.tsx index b8f45b66..fb3f2c72 100644 --- a/admin/src/components/AppLayout.tsx +++ b/admin/src/components/AppLayout.tsx @@ -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: , - label: 'Influence', + label: 'Advocacy', children: [ { key: '/app/campaigns', icon: , label: 'Campaigns' }, { key: '/app/campaign-moderation', icon: , label: 'Campaign Review' }, { key: '/app/representatives', icon: , label: 'Representatives' }, - { key: '/app/email-queue', icon: , label: 'Email Queue' }, - { key: '/app/responses', icon: , label: 'Responses' }, + { key: '/app/email-queue', icon: , label: 'Outgoing Emails' }, + { key: '/app/responses', icon: , label: badges?.pendingResponses ? Responses : 'Responses' }, + { key: '/app/influence/effectiveness', icon: , label: 'Effectiveness' }, ], }); } @@ -78,7 +87,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me icon: , label: 'Broadcast', children: [ - { key: '/app/listmonk', icon: , label: 'Listmonk' }, + { key: '/app/listmonk', icon: , label: 'Newsletter' }, { key: '/app/email-templates', icon: , label: 'Email Templates' }, ], }); @@ -90,6 +99,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me webChildren.push({ key: '/app/pages', icon: , label: 'Landing Pages' }); } webChildren.push({ key: '/app/docs', icon: , label: 'Documentation' }); + webChildren.push({ key: '/app/docs/analytics', icon: , label: 'Analytics' }); webChildren.push({ key: '/app/docs/settings', icon: , label: 'Docs Settings' }); webChildren.push({ key: '/app/code', icon: , label: 'Code Editor' }); items.push({ @@ -108,7 +118,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me { key: '/app/map', icon: , label: 'Locations' }, { key: '/app/map/data-quality', icon: , label: 'Data Quality' }, { key: '/app/map/shifts', icon: , label: 'Shifts' }, - { key: '/app/map/cuts', icon: , label: 'Cuts' }, + { key: '/app/map/cuts', icon: , label: 'Areas' }, { key: '/app/map/canvass', icon: , label: 'Canvassing' }, { key: '/app/map/settings', icon: , label: 'Settings' }, ], @@ -122,28 +132,51 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me label: 'Media Library', children: [ { key: '/app/media/library', icon: , label: 'Videos' }, + { key: '/app/media/analytics', icon: , label: 'Analytics' }, { key: '/app/media/curated', icon: , label: 'Curated' }, { key: '/app/media/moderation', icon: , label: 'Moderation' }, + { key: '/app/media/gallery-ads', icon: , label: 'Gallery Ads' }, { key: '/app/media/jobs', icon: , label: 'Processing Jobs' }, ], }); } - items.push({ - key: 'services-submenu', - icon: , - label: 'Services', - children: [ - { key: '/app/services/nocodb', icon: , label: 'Database' }, - { key: '/app/services/n8n', icon: , label: 'Workflows' }, - { key: '/app/services/gitea', icon: , label: 'Git' }, - { key: '/app/services/mailhog', icon: , label: 'MailHog' }, - { key: '/app/services/miniqr', icon: , label: 'QR Codes' }, - { key: '/app/services/excalidraw', icon: , label: 'Whiteboard' }, - { key: '/app/tunnel', icon: , label: 'Tunnel' }, - { key: '/app/observability', icon: , label: 'Observability' }, - ], - }); + if (settings?.enablePayments) { + items.push({ + key: 'payments-submenu', + icon: , + label: 'Payments', + children: [ + { key: '/app/payments', icon: , label: 'Dashboard' }, + { key: '/app/payments/subscribers', icon: , label: 'Subscribers' }, + { key: '/app/payments/products', icon: , label: 'Products' }, + { key: '/app/payments/donations', icon: , label: 'Donations' }, + { key: '/app/payments/settings', icon: , label: 'Settings' }, + ], + }); + } + + if (isSuperAdmin) { + items.push({ + key: 'services-submenu', + icon: , + label: 'Services', + children: [ + { type: 'group', label: 'Infrastructure', children: [ + { key: '/app/tunnel', icon: , label: 'Tunnel' }, + { key: '/app/observability', icon: , label: 'Monitoring' }, + { key: '/app/services/nocodb', icon: , label: 'Database' }, + { key: '/app/services/mailhog', icon: , label: 'MailHog' }, + ]}, + { type: 'group', label: 'Tools', children: [ + { key: '/app/services/n8n', icon: , label: 'Workflows' }, + { key: '/app/services/gitea', icon: , label: 'Git' }, + { key: '/app/services/excalidraw', icon: , label: 'Whiteboard' }, + { key: '/app/services/miniqr', icon: , 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: , + 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() { )}
{pageHeader?.actions} - - - - + + + + {settings?.enableInfluence !== false && ( + + + + )} + {settings?.enableMap !== false && ( + <> + + + + + + + + + + + )} + {settings?.enableMediaFeatures !== false && ( + + + + )} + {settings?.enablePayments && ( + <> + + + + + + + + + + + )} + )} /> ); } diff --git a/admin/src/components/GrapesJSEditor.tsx b/admin/src/components/GrapesJSEditor.tsx index 42b4a64d..f4d20b39 100644 --- a/admin/src/components/GrapesJSEditor.tsx +++ b/admin/src/components/GrapesJSEditor.tsx @@ -286,6 +286,68 @@ function generateBlockHtml(type: string, defaults: Record): str return `
${cardHtml}
`; } + 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 ` +
+
+
+

${heading}

+

${description}

+ ${buttonText} +
+
`; + } + 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 ` +
+
+
👑
+

${heading}

+

${description}

+
+
+

Free

+

$0

+

Access public content

+
+
+

Premium

+

$XX/mo

+

Plans load from API

+
+
+

Plans will load dynamically on published page

+
+
`; + } + case 'product-card': { + const productSlug = (defaults.productSlug as string) || ''; + const buttonText = (defaults.buttonText as string) || 'Buy Now'; + return ` +
+
+
+
+
🛒
+

Product Card

+

${productSlug || 'Set product slug'}

+
+
+
+
${productSlug || 'Product Name'}
+
Product details load on published page
+ ${buttonText} +
+
+
`; + } default: return `

Custom block: ${type}

`; } diff --git a/admin/src/components/PublicLayout.tsx b/admin/src/components/PublicLayout.tsx index 0b22669c..49956ba2 100644 --- a/admin/src/components/PublicLayout.tsx +++ b/admin/src/components/PublicLayout.tsx @@ -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 ( { e.currentTarget.style.color = '#fff'; }} + onMouseLeave={(e) => { if (!active) e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }} + > + {icon} + {label} + + ); +} + +function NavExternalLink({ href, icon, label }: { href: string; icon: React.ReactNode; label: string }) { + return ( + { e.currentTarget.style.color = '#fff'; }} onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }} > {icon} {label} - + ); } @@ -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() { {/* Right: Navigation */} - - {isAuthenticated ? ( - <> - } label="Create Campaign" /> - } label="My Campaigns" /> + {isMobile ? ( + + )} + + + Promoted + +
+ + ); +} + +/** 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')}`; +} diff --git a/admin/src/components/media/MediaSidebar.tsx b/admin/src/components/media/MediaSidebar.tsx index 0f8fa707..703a5060 100644 --- a/admin/src/components/media/MediaSidebar.tsx +++ b/admin/src/components/media/MediaSidebar.tsx @@ -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 */} +
+ + { 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)'; }} + > + + {!collapsed && Home} + + + +
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)'; }} + > + + {!collapsed && Back to Site} +
+
+
+ {/* Content Navigation Section */}
{/* Section header */} @@ -641,7 +693,7 @@ export default function MediaSidebar() { // Sign In button
navigate('/auth/login')} + onClick={() => navigate('/login')} style={{ display: 'flex', alignItems: 'center', diff --git a/admin/src/components/media/VideoCard.tsx b/admin/src/components/media/VideoCard.tsx index c3de1e27..4617169e 100644 --- a/admin/src/components/media/VideoCard.tsx +++ b/admin/src/components/media/VideoCard.tsx @@ -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({
{video.width} × {video.height}
{formatFileSize(video.fileSize)}
+ {video.accessLevel && video.accessLevel !== 'free' && ( + : } + > + {video.accessLevel === 'premium' ? 'Premium' : 'Member'} + + )} {video.producer && ( {video.producer} diff --git a/admin/src/components/payments/DonateInsertModal.tsx b/admin/src/components/payments/DonateInsertModal.tsx new file mode 100644 index 00000000..e1e73346 --- /dev/null +++ b/admin/src/components/payments/DonateInsertModal.tsx @@ -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('simple'); + const [amount, setAmount] = useState(25); + const [config, setConfig] = useState(null); + const [configLoading, setConfigLoading] = useState(false); + const [configError, setConfigError] = useState(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 ( + + + Choose a donation block style to insert into your document. + + + setVariant(e.target.value)} + style={{ width: '100%' }} + > + + {/* Simple CTA */} +
setVariant('simple')}> + + + +
+ Simple CTA Button +
+ + A styled button linking to the donate page + +
+
+
+
+ + {/* Set Amount */} +
setVariant('set-amount')}> + + + +
+ Set Amount Card +
+ + A card pre-configured for a specific dollar amount + +
+
+
+ {variant === 'set-amount' && ( +
+ Amount: + setAmount(v)} + style={{ width: 140 }} + size="small" + /> +
+ )} +
+ + {/* Full Standard */} +
setVariant('full')}> + + + +
+ Full Donate Card +
+ + Shows your configured title, description, and suggested amounts + +
+
+
+ {variant === 'full' && ( +
+ {configLoading && } + {configError && {configError}} + {config && ( +
+ Title: {config.donationPageTitle} +
+ Amounts: + {config.donationSuggestedAmounts.map(a => `$${(a / 100).toFixed(0)}`).join(', ')} +
+ )} +
+ )} +
+
+
+
+ ); +} diff --git a/admin/src/components/payments/DonationWidget.tsx b/admin/src/components/payments/DonationWidget.tsx new file mode 100644 index 00000000..a23a66b8 --- /dev/null +++ b/admin/src/components/payments/DonationWidget.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedAmount, setSelectedAmount] = useState(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(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
Loading...
; + } + + if (error || !config?.enableDonations) { + return ( +
+

{error || 'Donations are currently disabled'}

+ Go to donation page +
+ ); + } + + const suggestedAmounts = config.donationSuggestedAmounts || []; + + return ( +
+
+ {showAmounts && suggestedAmounts.length > 0 && ( +
+
Select an amount
+
+ {suggestedAmounts.map((amt) => ( + + ))} + +
+
+ )} + + {(!selectedAmount || !showAmounts) && ( +
+ + { setCustomAmount(e.target.value); setSelectedAmount(null); }} + placeholder={`Minimum $${((config.donationMinimum || 500) / 100).toFixed(2)}`} + style={styles.input} + /> +
+ )} + +
+ + setEmail(e.target.value)} + placeholder="your@email.com" + style={styles.input} + /> +
+ +
+ + setName(e.target.value)} + style={styles.input} + /> +
+ +
+ +