Semi working map cuts view; need to refactor and fix some stuff however stable enough to commit

This commit is contained in:
admin 2025-09-07 11:08:27 -06:00
parent 59491ccdc6
commit b3cd1a3331
9 changed files with 2141 additions and 4 deletions

View File

@ -1,6 +1,7 @@
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
const config = require('../config');
const spatialUtils = require('../utils/spatial');
class CutsController {
/**
@ -358,6 +359,272 @@ class CutsController {
});
}
}
/**
* Get all locations within a cut boundary - admin only
*/
async getLocationsInCut(req, res) {
try {
const { id } = req.params;
const { isAdmin } = req.user || {};
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
// Get the cut
const cut = await nocodbService.getById(config.CUTS_TABLE_ID, id);
if (!cut) {
return res.status(404).json({ error: 'Cut not found' });
}
// Get all locations
const locationsResponse = await nocodbService.getAll(config.LOCATIONS_TABLE_ID);
if (!locationsResponse || !locationsResponse.list) {
return res.json({ locations: [], statistics: { total_locations: 0 } });
}
// Apply filters from query params
const filters = {
support_level: req.query.support_level,
has_sign: req.query.has_sign === 'true' ? true : req.query.has_sign === 'false' ? false : undefined,
sign_size: req.query.sign_size,
has_email: req.query.has_email === 'true' ? true : req.query.has_email === 'false' ? false : undefined,
has_phone: req.query.has_phone === 'true' ? true : req.query.has_phone === 'false' ? false : undefined
};
// Filter locations within cut boundaries
const filteredLocations = spatialUtils.filterLocationsInCut(
locationsResponse.list,
cut,
filters
);
// Calculate statistics
const statistics = spatialUtils.calculateCutStatistics(filteredLocations);
const cutName = cut.name || cut.Name || cut.title || cut.Title || 'Unknown';
logger.info(`Found ${filteredLocations.length} locations in cut: ${cutName}`);
res.json({
locations: filteredLocations,
statistics,
cut: { id: cut.id || cut.Id || cut.ID, name: cutName }
});
} catch (error) {
logger.error('Error getting locations in cut:', error);
res.status(500).json({
error: 'Failed to get locations in cut',
details: error.message
});
}
}
/**
* Export locations within a cut as CSV - admin only
*/
async exportCutLocations(req, res) {
try {
const { id } = req.params;
const { isAdmin } = req.user || {};
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
// Get the cut
const cut = await nocodbService.getById(config.CUTS_TABLE_ID, id);
if (!cut) {
return res.status(404).json({ error: 'Cut not found' });
}
// Check if export is enabled for this cut
if (cut.export_enabled === false) {
return res.status(403).json({ error: 'Export is disabled for this cut' });
}
// Get all locations
const locationsResponse = await nocodbService.getAll(config.LOCATIONS_TABLE_ID);
if (!locationsResponse || !locationsResponse.list) {
return res.json({ locations: [] });
}
// Apply filters from query params
const filters = {
support_level: req.query.support_level,
has_sign: req.query.has_sign === 'true' ? true : req.query.has_sign === 'false' ? false : undefined,
sign_size: req.query.sign_size,
has_email: req.query.has_email === 'true' ? true : req.query.has_email === 'false' ? false : undefined,
has_phone: req.query.has_phone === 'true' ? true : req.query.has_phone === 'false' ? false : undefined
};
// Filter locations within cut boundaries
const filteredLocations = spatialUtils.filterLocationsInCut(
locationsResponse.list,
cut,
filters
);
// Generate CSV content
const csvHeaders = [
'ID', 'First Name', 'Last Name', 'Email', 'Phone', 'Address',
'Unit Number', 'Support Level', 'Has Sign', 'Sign Size',
'Latitude', 'Longitude', 'Notes'
];
const csvRows = filteredLocations.map(location => [
location.id || '',
location.first_name || '',
location.last_name || '',
location.email || '',
location.phone || '',
location.address || '',
location.unit_number || '',
location.support_level || '',
location.sign ? 'Yes' : 'No',
location.sign_size || '',
location.latitude || '',
location.longitude || '',
(location.notes || '').replace(/"/g, '""') // Escape quotes in notes
]);
const csvContent = [
csvHeaders.join(','),
...csvRows.map(row => row.map(field => `"${field}"`).join(','))
].join('\n');
const cutName = (cut.name || 'cut').replace(/[^a-zA-Z0-9]/g, '_');
const timestamp = new Date().toISOString().split('T')[0];
const filename = `${cutName}_locations_${timestamp}.csv`;
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(csvContent);
logger.info(`Exported ${filteredLocations.length} locations from cut: ${cut.name}`);
} catch (error) {
logger.error('Error exporting cut locations:', error);
res.status(500).json({
error: 'Failed to export cut locations',
details: error.message
});
}
}
/**
* Update cut settings (visibility, filters, etc.) - admin only
*/
async updateCutSettings(req, res) {
try {
const { id } = req.params;
const { isAdmin } = req.user || {};
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const {
show_locations,
export_enabled,
assigned_to,
filter_settings,
last_canvassed,
completion_percentage
} = req.body;
const updateData = {
updated_at: new Date().toISOString()
};
// Only include fields that are provided
if (show_locations !== undefined) updateData.show_locations = show_locations;
if (export_enabled !== undefined) updateData.export_enabled = export_enabled;
if (assigned_to !== undefined) updateData.assigned_to = assigned_to;
if (filter_settings !== undefined) updateData.filter_settings = JSON.stringify(filter_settings);
if (last_canvassed !== undefined) updateData.last_canvassed = last_canvassed;
if (completion_percentage !== undefined) updateData.completion_percentage = completion_percentage;
const response = await nocodbService.update(
config.CUTS_TABLE_ID,
id,
updateData
);
logger.info(`Updated cut settings for cut ID: ${id}`);
res.json(response);
} catch (error) {
logger.error('Error updating cut settings:', error);
res.status(500).json({
error: 'Failed to update cut settings',
details: error.message
});
}
}
/**
* Get cut statistics - admin only
*/
async getCutStatistics(req, res) {
try {
const { id } = req.params;
const { isAdmin } = req.user || {};
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
// Get the cut
const cut = await nocodbService.getById(config.CUTS_TABLE_ID, id);
if (!cut) {
return res.status(404).json({ error: 'Cut not found' });
}
// Get all locations
const locationsResponse = await nocodbService.getAll(config.LOCATIONS_TABLE_ID);
if (!locationsResponse || !locationsResponse.list) {
return res.json({
statistics: { total_locations: 0 },
cut: { id: cut.id, name: cut.name }
});
}
// Get locations within cut boundaries (no additional filters)
const locationsInCut = spatialUtils.filterLocationsInCut(
locationsResponse.list,
cut,
{} // No additional filters for statistics
);
// Calculate statistics
const statistics = spatialUtils.calculateCutStatistics(locationsInCut);
// Add cut metadata
const cutStats = {
...statistics,
cut_metadata: {
id: cut.id,
name: cut.name,
category: cut.category,
assigned_to: cut.assigned_to,
completion_percentage: cut.completion_percentage || 0,
last_canvassed: cut.last_canvassed,
created_at: cut.created_at
}
};
logger.info(`Generated statistics for cut: ${cut.name}`);
res.json({ statistics: cutStats });
} catch (error) {
logger.error('Error getting cut statistics:', error);
res.status(500).json({
error: 'Failed to get cut statistics',
details: error.message
});
}
}
}
module.exports = new CutsController();

View File

@ -846,6 +846,134 @@
</div>
</div>
<!-- Cut Location Management -->
<div class="cuts-location-section" id="cut-location-management" style="display: none;">
<div class="cuts-management-panel">
<div class="panel-header">
<h3 class="panel-title">Location Management</h3>
<div class="panel-actions">
<button id="toggle-location-visibility" class="btn btn-primary btn-sm">Show Locations</button>
<button id="export-cut-locations" class="btn btn-success btn-sm">Export Data</button>
<button id="print-cut-view" class="btn btn-secondary btn-sm">Print View</button>
</div>
</div>
<div class="panel-content">
<!-- Location Filters -->
<div class="location-filters">
<h4>Filter Locations</h4>
<div class="filter-row">
<div class="filter-group">
<label for="support-level-filter">Support Level:</label>
<select id="support-level-filter" class="form-control">
<option value="">All Levels</option>
<option value="1">Level 1 (Strong Support)</option>
<option value="2">Level 2 (Lean Support)</option>
<option value="3">Level 3 (Lean Opposition)</option>
<option value="4">Level 4 (Strong Opposition)</option>
</select>
</div>
<div class="filter-group">
<label for="sign-status-filter">Lawn Sign:</label>
<select id="sign-status-filter" class="form-control">
<option value="">All</option>
<option value="true">Has Sign</option>
<option value="false">No Sign</option>
</select>
</div>
<div class="filter-group">
<label for="sign-size-filter">Sign Size:</label>
<select id="sign-size-filter" class="form-control">
<option value="">All Sizes</option>
<option value="Regular">Regular</option>
<option value="Large">Large</option>
<option value="Unsure">Unsure</option>
</select>
</div>
<div class="filter-group">
<label for="contact-filter">Contact Info:</label>
<select id="contact-filter" class="form-control">
<option value="">All</option>
<option value="email">Has Email</option>
<option value="phone">Has Phone</option>
<option value="both">Has Both</option>
</select>
</div>
<div class="filter-group">
<button id="apply-filters" class="btn btn-primary">Apply Filters</button>
<button id="clear-filters" class="btn btn-secondary">Clear</button>
</div>
</div>
</div>
<!-- Cut Statistics -->
<div class="cut-statistics" id="cut-statistics" style="display: none;">
<h4>Statistics</h4>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Total Locations:</span>
<span class="stat-value" id="total-locations">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Support Level 1:</span>
<span class="stat-value" id="support-1">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Support Level 2:</span>
<span class="stat-value" id="support-2">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Has Lawn Signs:</span>
<span class="stat-value" id="has-signs">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Has Email:</span>
<span class="stat-value" id="has-email">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Has Phone:</span>
<span class="stat-value" id="has-phone">0</span>
</div>
</div>
</div>
<!-- Cut Settings -->
<div class="cut-settings">
<h4>Cut Settings</h4>
<div class="setting-row">
<div class="setting-group">
<label>
<input type="checkbox" id="show-locations-toggle" checked>
Show locations on map
</label>
</div>
<div class="setting-group">
<label>
<input type="checkbox" id="export-enabled-toggle" checked>
Enable data export
</label>
</div>
<div class="setting-group">
<label for="assigned-to">Assigned to:</label>
<input type="text" id="assigned-to" class="form-control" placeholder="Volunteer name/email">
</div>
<div class="setting-group">
<label for="completion-percentage">Completion:</label>
<input type="number" id="completion-percentage" class="form-control" min="0" max="100" placeholder="0">
<span>%</span>
</div>
<div class="setting-group">
<button id="save-cut-settings" class="btn btn-success">Save Settings</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Cuts List -->
<div class="cuts-list-section">
<div class="cuts-management-panel">
@ -1242,6 +1370,10 @@
<!-- Chart.js library -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- dom-to-image library for map screenshots -->
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dom-to-image@2.6.0/dist/dom-to-image.min.js"></script>
<!-- Dashboard JavaScript -->
<script src="js/dashboard.js"></script>

View File

@ -994,3 +994,248 @@
color: #666;
margin-top: 2px;
}
/* Cut Location Management Styles */
.cuts-location-section {
margin-top: 20px;
}
.location-filters {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.location-filters h4 {
margin: 0 0 15px 0;
color: #495057;
font-size: 16px;
font-weight: 600;
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 15px;
align-items: end;
}
.filter-group {
display: flex;
flex-direction: column;
min-width: 150px;
}
.filter-group label {
font-size: 12px;
color: #6c757d;
margin-bottom: 5px;
font-weight: 500;
}
.filter-group .form-control {
padding: 6px 10px;
font-size: 14px;
border: 1px solid #dee2e6;
border-radius: 4px;
}
.filter-group button {
white-space: nowrap;
}
/* Cut Statistics Styles */
.cut-statistics {
background: #e9ecef;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.cut-statistics h4 {
margin: 0 0 15px 0;
color: #495057;
font-size: 16px;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: white;
border-radius: 4px;
border-left: 4px solid #007bff;
}
.stat-label {
font-size: 14px;
color: #6c757d;
font-weight: 500;
}
.stat-value {
font-size: 14px;
color: #212529;
font-weight: 600;
}
/* Cut Settings Styles */
.cut-settings {
background: #f1f3f4;
padding: 15px;
border-radius: 8px;
}
.cut-settings h4 {
margin: 0 0 15px 0;
color: #495057;
font-size: 16px;
font-weight: 600;
}
.setting-row {
display: flex;
flex-wrap: wrap;
gap: 15px;
align-items: center;
}
.setting-group {
display: flex;
flex-direction: column;
min-width: 150px;
}
.setting-group label {
font-size: 14px;
color: #495057;
margin-bottom: 5px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.setting-group input[type="checkbox"] {
margin: 0;
}
.setting-group input[type="text"],
.setting-group input[type="number"] {
padding: 6px 10px;
font-size: 14px;
border: 1px solid #dee2e6;
border-radius: 4px;
}
.setting-group span {
font-size: 14px;
color: #6c757d;
margin-left: 5px;
}
/* Button States for Location Management */
#toggle-location-visibility.active {
background-color: #28a745;
border-color: #28a745;
}
#toggle-location-visibility.inactive {
background-color: #6c757d;
border-color: #6c757d;
}
/* Responsive Design */
@media (max-width: 768px) {
.filter-row {
flex-direction: column;
align-items: stretch;
}
.filter-group {
min-width: 100%;
}
.setting-row {
flex-direction: column;
align-items: stretch;
}
.setting-group {
min-width: 100%;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
/* Enhanced panel styling for location management */
.cuts-location-section .panel-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.cuts-location-section .panel-title {
color: white;
}
.cuts-location-section .btn-sm {
font-size: 12px;
padding: 4px 8px;
}
/* Print View Styles */
@media print {
.cuts-location-section {
page-break-inside: avoid;
}
.location-filters,
.cut-settings {
display: none;
}
.cut-statistics {
border: 1px solid #000;
background: white !important;
}
}
/* Print-specific map styles */
@media screen {
.leaflet-container img {
max-width: none !important;
opacity: 1 !important;
}
.leaflet-tile-pane {
opacity: 1 !important;
}
.leaflet-overlay-pane {
opacity: 1 !important;
}
}
/* Ensure tiles are visible during capture */
.leaflet-tile {
opacity: 1 !important;
visibility: visible !important;
}
.leaflet-container .leaflet-tile-pane {
z-index: 1 !important;
}
.leaflet-container .leaflet-overlay-pane {
z-index: 2 !important;
}

File diff suppressed because it is too large Load Diff

View File

@ -27,4 +27,10 @@ router.post('/', requireAdmin, cutsController.create);
router.put('/:id', requireAdmin, cutsController.update);
router.delete('/:id', requireAdmin, cutsController.delete);
// New cut enhancement routes - admin only
router.get('/:id/locations', requireAdmin, cutsController.getLocationsInCut);
router.get('/:id/locations/export', requireAdmin, cutsController.exportCutLocations);
router.get('/:id/statistics', requireAdmin, cutsController.getCutStatistics);
router.put('/:id/settings', requireAdmin, cutsController.updateCutSettings);
module.exports = router;

View File

@ -84,6 +84,9 @@ const buildConnectSrc = () => {
// Add Nominatim for geocoding
sources.push('https://nominatim.openstreetmap.org');
// Add OpenStreetMap tile servers for dom-to-image map capture
sources.push('https://*.tile.openstreetmap.org');
// Add localhost for development
if (!config.isProduction) {
sources.push('http://localhost:*');
@ -100,7 +103,7 @@ app.use(helmet({
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://cdn.jsdelivr.net"],
imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://tiles.stadiamaps.com", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "blob:", "https://*.tile.openstreetmap.org", "https://tiles.stadiamaps.com", "https://unpkg.com"],
connectSrc: buildConnectSrc()
}
}

188
map/app/utils/spatial.js Normal file
View File

@ -0,0 +1,188 @@
/**
* Spatial Operations Utility
* Provides point-in-polygon calculations and location filtering within cut boundaries
*/
/**
* Check if a point is inside a polygon using ray casting algorithm
* @param {number} lat - Point latitude
* @param {number} lng - Point longitude
* @param {Array} polygon - Array of [lng, lat] coordinates
* @returns {boolean} True if point is inside polygon
*/
function isPointInPolygon(lat, lng, polygon) {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i][0], yi = polygon[i][1];
const xj = polygon[j][0], yj = polygon[j][1];
if (((yi > lat) !== (yj > lat)) &&
(lng < (xj - xi) * (lat - yi) / (yj - yi) + xi)) {
inside = !inside;
}
}
return inside;
}
/**
* Check if a location is within a cut's GeoJSON polygon
* @param {Object} location - Location object with latitude/longitude
* @param {Object} cutGeoJson - GeoJSON polygon object
* @returns {boolean} True if location is within cut
*/
function isLocationInCut(location, cutGeoJson) {
// Handle different possible field names for coordinates
const lat = location.latitude || location.Latitude || location.lat;
const lng = location.longitude || location.Longitude || location.lng || location.lon;
if (!lat || !lng || !cutGeoJson) {
return false;
}
try {
const geojson = typeof cutGeoJson === 'string' ? JSON.parse(cutGeoJson) : cutGeoJson;
if (geojson.type === 'Polygon') {
return isPointInPolygon(lat, lng, geojson.coordinates[0]);
} else if (geojson.type === 'MultiPolygon') {
return geojson.coordinates.some(polygon =>
isPointInPolygon(lat, lng, polygon[0])
);
}
return false;
} catch (error) {
console.error('Error checking point in polygon:', error);
return false;
}
}
/**
* Filter locations based on cut boundaries and additional criteria
* @param {Array} locations - Array of location objects
* @param {Object} cut - Cut object with geojson
* @param {Object} filters - Additional filter criteria
* @returns {Array} Filtered locations within cut
*/
function filterLocationsInCut(locations, cut, filters = {}) {
// Try multiple possible field names for the geojson data
const geojsonData = cut.geojson || cut.Geojson || cut.GeoJSON || cut['GeoJSON Data'] || cut.geojson_data;
if (!geojsonData) {
return [];
}
// First filter by geographic boundaries
let filteredLocations = locations.filter(location =>
isLocationInCut(location, geojsonData)
);
// Apply additional filters
if (filters.support_level) {
filteredLocations = filteredLocations.filter(location => {
const supportLevel = location.support_level || location['Support Level'];
return supportLevel === filters.support_level;
});
}
if (filters.has_sign !== undefined) {
filteredLocations = filteredLocations.filter(location => {
const hasSign = location.sign || location.Sign;
return Boolean(hasSign) === filters.has_sign;
});
}
if (filters.sign_size) {
filteredLocations = filteredLocations.filter(location => {
const signSize = location.sign_size || location['Sign Size'];
return signSize === filters.sign_size;
});
}
if (filters.has_email !== undefined) {
filteredLocations = filteredLocations.filter(location => {
const email = location.email || location.Email;
return Boolean(email) === filters.has_email;
});
}
if (filters.has_phone !== undefined) {
filteredLocations = filteredLocations.filter(location => {
const phone = location.phone || location.Phone;
return Boolean(phone) === filters.has_phone;
});
}
return filteredLocations;
}
/**
* Calculate statistics for locations within a cut
* @param {Array} locations - Array of location objects within cut
* @returns {Object} Statistics object
*/
function calculateCutStatistics(locations) {
const stats = {
total_locations: locations.length,
support_levels: { '1': 0, '2': 0, '3': 0, '4': 0, 'unknown': 0 },
lawn_signs: { has_sign: 0, no_sign: 0, unknown: 0 },
sign_sizes: { Regular: 0, Large: 0, Unsure: 0, unknown: 0 },
contact_info: { has_email: 0, has_phone: 0, has_both: 0, has_neither: 0 }
};
locations.forEach(location => {
// Support level stats - handle different field names
const supportLevel = location.support_level || location['Support Level'] || 'unknown';
if (stats.support_levels.hasOwnProperty(supportLevel)) {
stats.support_levels[supportLevel]++;
} else {
stats.support_levels.unknown++;
}
// Lawn sign stats - handle different field names
const sign = location.sign || location.Sign;
if (sign === true || sign === 1 || sign === 'true') {
stats.lawn_signs.has_sign++;
} else if (sign === false || sign === 0 || sign === 'false') {
stats.lawn_signs.no_sign++;
} else {
stats.lawn_signs.unknown++;
}
// Sign size stats - handle different field names
const signSize = location.sign_size || location['Sign Size'] || 'unknown';
if (stats.sign_sizes.hasOwnProperty(signSize)) {
stats.sign_sizes[signSize]++;
} else {
stats.sign_sizes.unknown++;
}
// Contact info stats - handle different field names
const email = location.email || location.Email;
const phone = location.phone || location.Phone;
const hasEmail = Boolean(email);
const hasPhone = Boolean(phone);
if (hasEmail && hasPhone) {
stats.contact_info.has_both++;
} else if (hasEmail) {
stats.contact_info.has_email++;
} else if (hasPhone) {
stats.contact_info.has_phone++;
} else {
stats.contact_info.has_neither++;
}
});
return stats;
}
module.exports = {
isPointInPolygon,
isLocationInCut,
filterLocationsInCut,
calculateCutStatistics
};

View File

@ -883,6 +883,45 @@ create_cuts_table() {
"title": "Updated At",
"uidt": "DateTime",
"rqd": false
},
{
"column_name": "show_locations",
"title": "Show Locations",
"uidt": "Checkbox",
"rqd": false,
"cdf": true
},
{
"column_name": "export_enabled",
"title": "Export Enabled",
"uidt": "Checkbox",
"rqd": false,
"cdf": true
},
{
"column_name": "assigned_to",
"title": "Assigned To",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "filter_settings",
"title": "Filter Settings",
"uidt": "LongText",
"rqd": false
},
{
"column_name": "last_canvassed",
"title": "Last Canvassed",
"uidt": "DateTime",
"rqd": false
},
{
"column_name": "completion_percentage",
"title": "Completion Percentage",
"uidt": "Number",
"rqd": false,
"cdf": "0"
}
]
}'

View File

@ -1341,8 +1341,8 @@
<section class="problem-section">
<div class="section-content">
<div class="section-header">
<h2>Your Canvassers Are Struggling</h2>
<p>Traditional campaign tools weren't built for the reality of door-to-door work</p>
<h2>Your Supporters Are Struggling</h2>
<p>Traditional campaign tools weren't built for the reality of political action</p>
</div>
<div class="problem-grid">
<div class="problem-card">
@ -1383,7 +1383,7 @@
<section class="solution-section" id="features">
<div class="section-content">
<div class="section-header">
<h2>Documentation That Works</h2>
<h2>Political Documentation That Works</h2>
<p>Everything your team needs, instantly searchable, always accessible, and easy to communicate</p>
</div>