31 KiB
31 KiB
Alberta Influence Campaign Tool - Complete Setup Guide
Project Overview
A locally-hosted political influence campaign tool for Alberta constituents to contact their representatives via email. Uses the Represent OpenNorth API for representative data and provides both self-service and campaign modes.
Directory Structure
influence-campaign/
├── docker-compose.yml
├── .env.example
├── app/
│ ├── Dockerfile
│ ├── package.json
│ ├── server.js
│ ├── controllers/
│ │ ├── representatives.js
│ │ └── emails.js
│ ├── routes/
│ │ └── api.js
│ ├── services/
│ │ ├── nocodb.js
│ │ ├── represent-api.js
│ │ └── email.js
│ ├── utils/
│ │ ├── validators.js
│ │ └── rate-limiter.js
│ └── public/
│ ├── index.html
│ ├── css/
│ │ └── styles.css
│ └── js/
│ ├── main.js
│ ├── api-client.js
│ └── postal-lookup.js
├── scripts/
│ └── build-nocodb.sh
└── README.md
1. Docker Setup
docker-compose.yml
version: '3.8'
services:
app:
build: ./app
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- PORT=3000
- NOCODB_URL=http://nocodb:8080
env_file:
- .env
depends_on:
- nocodb
volumes:
- ./app:/usr/src/app
- /usr/src/app/node_modules
command: npm run dev
restart: unless-stopped
nocodb:
image: nocodb/nocodb:latest
ports:
- "8080:8080"
environment:
- NC_DB=/usr/app/data/noco.db
- NC_AUTH_JWT_SECRET=${NOCODB_AUTH_SECRET}
volumes:
- nocodb-data:/usr/app/data
restart: unless-stopped
volumes:
nocodb-data:
.env.example
# NocoDB Configuration
NOCODB_URL=http://nocodb:8080
NOCODB_API_TOKEN=your_nocodb_api_token_here
NOCODB_AUTH_SECRET=your_jwt_secret_here
# SMTP Configuration (e.g., SendGrid, Gmail, etc.)
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=your_smtp_password_here
SMTP_FROM_EMAIL=noreply@yourcampaign.ca
SMTP_FROM_NAME=Alberta Influence Campaign
# Admin Configuration
ADMIN_PASSWORD=secure_admin_password_here
# Represent API
REPRESENT_API_BASE=https://represent.opennorth.ca
REPRESENT_API_RATE_LIMIT=60
# App Configuration
APP_URL=http://localhost:3000
SESSION_SECRET=your_session_secret_here
app/Dockerfile
FROM node:18-alpine
WORKDIR /usr/src/app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy app files
COPY . .
# Expose port
EXPOSE 3000
# Start the application
CMD ["node", "server.js"]
2. Backend Implementation
app/package.json
{
"name": "alberta-influence-campaign",
"version": "1.0.0",
"description": "Political influence campaign tool for Alberta",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"axios": "^1.6.2",
"nodemailer": "^6.9.7",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"express-validator": "^7.0.1"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}
app/server.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const path = require('path');
require('dotenv').config();
const apiRoutes = require('./routes/api');
const app = express();
const PORT = process.env.PORT || 3000;
// Security middleware
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "https:", "data:"],
},
},
}));
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// Routes
app.use('/api', apiRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: {
message: err.message || 'Internal server error',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV}`);
});
app/routes/api.js
const express = require('express');
const router = express.Router();
const { body, param, validationResult } = require('express-validator');
const representativesController = require('../controllers/representatives');
const emailsController = require('../controllers/emails');
const rateLimiter = require('../utils/rate-limiter');
// Validation middleware
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
};
// Test endpoints
router.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
router.get('/test-represent', representativesController.testConnection);
// Representatives endpoints
router.get(
'/representatives/by-postal/:postalCode',
param('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/),
handleValidationErrors,
rateLimiter.general,
representativesController.getByPostalCode
);
router.post(
'/representatives/refresh-postal/:postalCode',
param('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/),
handleValidationErrors,
representativesController.refreshPostalCode
);
// Email endpoints
router.post(
'/emails/send',
[
body('from_email').isEmail(),
body('to_email').isEmail(),
body('subject').notEmpty().isLength({ max: 200 }),
body('message').notEmpty().isLength({ min: 10, max: 5000 }),
body('representative_name').notEmpty()
],
handleValidationErrors,
rateLimiter.email,
emailsController.sendEmail
);
module.exports = router;
app/services/represent-api.js
const axios = require('axios');
class RepresentAPIService {
constructor() {
this.baseURL = process.env.REPRESENT_API_BASE || 'https://represent.opennorth.ca';
this.requestCount = 0;
this.resetTime = Date.now() + 60000; // Reset every minute
}
async checkRateLimit() {
if (Date.now() > this.resetTime) {
this.requestCount = 0;
this.resetTime = Date.now() + 60000;
}
if (this.requestCount >= 60) {
throw new Error('Rate limit exceeded. Please wait before making more requests.');
}
this.requestCount++;
}
formatPostalCode(postalCode) {
// Remove spaces and convert to uppercase
return postalCode.replace(/\s/g, '').toUpperCase();
}
async getByPostalCode(postalCode) {
try {
await this.checkRateLimit();
const formattedPostal = this.formatPostalCode(postalCode);
// Validate Alberta postal code (starts with T)
if (!formattedPostal.startsWith('T')) {
throw new Error('Only Alberta postal codes (starting with T) are supported');
}
const response = await axios.get(`${this.baseURL}/postcodes/${formattedPostal}/`, {
timeout: 10000,
headers: {
'User-Agent': 'Alberta Influence Campaign Tool'
}
});
return this.processRepresentatives(response.data);
} catch (error) {
if (error.response?.status === 404) {
throw new Error('Postal code not found');
}
throw error;
}
}
processRepresentatives(data) {
const representatives = [];
// Combine centroid and concordance representatives
const allReps = [
...(data.representatives_centroid || []),
...(data.representatives_concordance || [])
];
// De-duplicate based on name and elected_office
const seen = new Set();
const uniqueReps = allReps.filter(rep => {
const key = `${rep.name}-${rep.elected_office}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
// Filter and categorize Alberta representatives
uniqueReps.forEach(rep => {
if (this.isAlbertaRepresentative(rep)) {
representatives.push({
...rep,
level: this.determineLevel(rep),
contact_info: this.extractContactInfo(rep)
});
}
});
return {
postal_code: data.code,
city: data.city,
province: data.province,
representatives: this.organizeByLevel(representatives)
};
}
isAlbertaRepresentative(rep) {
// Federal MPs for Alberta ridings
if (rep.elected_office === 'MP') {
return rep.district_name && (
rep.district_name.includes('Alberta') ||
rep.district_name.includes('Calgary') ||
rep.district_name.includes('Edmonton')
);
}
// Provincial MLAs
if (rep.elected_office === 'MLA') {
return true;
}
// Municipal representatives
const albertaCities = ['Calgary', 'Edmonton', 'Red Deer', 'Lethbridge',
'Medicine Hat', 'St. Albert', 'Airdrie', 'Spruce Grove'];
if (rep.elected_office && ['Mayor', 'Councillor', 'Alderman'].includes(rep.elected_office)) {
return albertaCities.some(city =>
rep.district_name && rep.district_name.includes(city)
);
}
return false;
}
determineLevel(rep) {
if (rep.elected_office === 'MP') return 'federal';
if (rep.elected_office === 'MLA') return 'provincial';
if (['Mayor', 'Councillor', 'Alderman'].includes(rep.elected_office)) return 'municipal';
return 'other';
}
extractContactInfo(rep) {
const contact = {
email: rep.email || null,
offices: []
};
if (rep.offices && Array.isArray(rep.offices)) {
rep.offices.forEach(office => {
contact.offices.push({
type: office.type || 'constituency',
phone: office.tel || null,
fax: office.fax || null,
address: office.postal || null
});
});
}
return contact;
}
organizeByLevel(representatives) {
return {
federal: representatives.filter(r => r.level === 'federal'),
provincial: representatives.filter(r => r.level === 'provincial'),
municipal: representatives.filter(r => r.level === 'municipal')
};
}
}
module.exports = new RepresentAPIService();
app/services/nocodb.js
const axios = require('axios');
class NocoDBService {
constructor() {
this.baseURL = process.env.NOCODB_URL;
this.apiToken = process.env.NOCODB_API_TOKEN;
this.projectName = 'influence_campaign';
this.client = axios.create({
baseURL: `${this.baseURL}/api/v1/db/data/v1/${this.projectName}`,
headers: {
'xc-auth': this.apiToken
},
timeout: 10000
});
}
// Generic methods
async create(table, data) {
try {
const response = await this.client.post(`/${table}`, data);
return response.data;
} catch (error) {
console.error(`Error creating record in ${table}:`, error.message);
throw error;
}
}
async find(table, id) {
try {
const response = await this.client.get(`/${table}/${id}`);
return response.data;
} catch (error) {
console.error(`Error finding record in ${table}:`, error.message);
throw error;
}
}
async list(table, params = {}) {
try {
const response = await this.client.get(`/${table}`, { params });
return response.data;
} catch (error) {
console.error(`Error listing records from ${table}:`, error.message);
throw error;
}
}
async update(table, id, data) {
try {
const response = await this.client.patch(`/${table}/${id}`, data);
return response.data;
} catch (error) {
console.error(`Error updating record in ${table}:`, error.message);
throw error;
}
}
// Specific methods for postal cache
async getCachedPostal(postalCode) {
try {
const results = await this.list('postal_cache', {
where: `(postal_code,eq,${postalCode})`,
limit: 1
});
if (results.list && results.list.length > 0) {
const cached = results.list[0];
const cacheAge = Date.now() - new Date(cached.cached_at).getTime();
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
if (cacheAge < maxAge) {
return JSON.parse(cached.response_json);
}
}
return null;
} catch (error) {
console.error('Error getting cached postal:', error.message);
return null;
}
}
async cachePostal(postalCode, data) {
try {
const existing = await this.list('postal_cache', {
where: `(postal_code,eq,${postalCode})`,
limit: 1
});
const cacheData = {
postal_code: postalCode,
response_json: JSON.stringify(data),
cached_at: new Date().toISOString()
};
if (existing.list && existing.list.length > 0) {
await this.update('postal_cache', existing.list[0].id, cacheData);
} else {
await this.create('postal_cache', cacheData);
}
} catch (error) {
console.error('Error caching postal:', error.message);
}
}
// Email logging
async logEmail(emailData) {
try {
return await this.create('sent_emails', {
from_email: emailData.from_email,
to_email: emailData.to_email,
representative_name: emailData.representative_name,
subject: emailData.subject,
body: emailData.body,
sent_at: new Date().toISOString(),
status: emailData.status || 'sent'
});
} catch (error) {
console.error('Error logging email:', error.message);
// Don't throw - logging failure shouldn't stop email sending
}
}
}
module.exports = new NocoDBService();
app/services/email.js
const nodemailer = require('nodemailer');
class EmailService {
constructor() {
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT || 587,
secure: process.env.SMTP_PORT === '465',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
}
async sendEmail({ from_email, to_email, subject, message, representative_name }) {
try {
const mailOptions = {
from: `${process.env.SMTP_FROM_NAME} <${process.env.SMTP_FROM_EMAIL}>`,
to: to_email,
subject: subject,
text: message,
html: this.generateHTMLEmail(message, from_email, representative_name),
replyTo: from_email,
headers: {
'X-Sender-Email': from_email,
'X-Campaign': 'Alberta Influence Campaign'
}
};
const info = await this.transporter.sendMail(mailOptions);
return {
success: true,
messageId: info.messageId,
response: info.response
};
} catch (error) {
console.error('Email send error:', error);
throw new Error(`Failed to send email: ${error.message}`);
}
}
generateHTMLEmail(message, from_email, representative_name) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.header { background-color: #f4f4f4; padding: 10px; margin-bottom: 20px; }
.message { padding: 20px; }
.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #ccc; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="header">
<strong>Message from Constituent</strong><br>
Email: ${from_email}
</div>
<div class="message">
<p>Dear ${representative_name},</p>
${message.split('\n').map(para => `<p>${para}</p>`).join('')}
</div>
<div class="footer">
This email was sent via the Alberta Influence Campaign platform.<br>
The constituent's email address is: ${from_email}
</div>
</body>
</html>
`;
}
async verifyConnection() {
try {
await this.transporter.verify();
return { connected: true };
} catch (error) {
return { connected: false, error: error.message };
}
}
}
module.exports = new EmailService();
app/controllers/representatives.js
const representAPI = require('../services/represent-api');
const nocoDB = require('../services/nocodb');
class RepresentativesController {
async getByPostalCode(req, res, next) {
try {
const { postalCode } = req.params;
// Check cache first
let data = await nocoDB.getCachedPostal(postalCode);
if (!data) {
// Fetch from API
data = await representAPI.getByPostalCode(postalCode);
// Cache the result
await nocoDB.cachePostal(postalCode, data);
}
res.json({
success: true,
data,
cached: !!data.from_cache
});
} catch (error) {
next(error);
}
}
async refreshPostalCode(req, res, next) {
try {
const { postalCode } = req.params;
// Check admin auth
const authHeader = req.headers.authorization;
if (!authHeader || authHeader !== `Bearer ${process.env.ADMIN_PASSWORD}`) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Force fetch from API
const data = await representAPI.getByPostalCode(postalCode);
// Update cache
await nocoDB.cachePostal(postalCode, data);
res.json({
success: true,
message: 'Cache refreshed',
data
});
} catch (error) {
next(error);
}
}
async testConnection(req, res, next) {
try {
// Test with Edmonton postal code
const testPostal = 'T5J2R7';
const data = await representAPI.getByPostalCode(testPostal);
res.json({
success: true,
message: 'Represent API connection successful',
test_postal: testPostal,
representatives_found: {
federal: data.representatives.federal.length,
provincial: data.representatives.provincial.length,
municipal: data.representatives.municipal.length
}
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Represent API connection failed',
error: error.message
});
}
}
}
module.exports = new RepresentativesController();
app/controllers/emails.js
const emailService = require('../services/email');
const nocoDB = require('../services/nocodb');
class EmailsController {
async sendEmail(req, res, next) {
try {
const { from_email, to_email, subject, message, representative_name } = req.body;
// Send email
const result = await emailService.sendEmail({
from_email,
to_email,
subject,
message,
representative_name
});
// Log to database
await nocoDB.logEmail({
from_email,
to_email,
representative_name,
subject,
body: message,
status: 'sent'
});
res.json({
success: true,
message: 'Email sent successfully',
messageId: result.messageId
});
} catch (error) {
// Log failed attempt
await nocoDB.logEmail({
...req.body,
status: 'failed',
error: error.message
});
next(error);
}
}
}
module.exports = new EmailsController();
app/utils/rate-limiter.js
const rateLimit = require('express-rate-limit');
// General API rate limiter
const general = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP, please try again later.',
standardHeaders: true,
legacyHeaders: false,
});
// Email sending rate limiter
const email = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10, // Limit to 10 emails per hour per IP
message: 'Email sending limit reached. Please wait before sending more emails.',
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false,
});
module.exports = {
general,
email
};
app/utils/validators.js
// Validate Canadian postal code format
function validatePostalCode(postalCode) {
const regex = /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/;
return regex.test(postalCode);
}
// Validate Alberta postal code (starts with T)
function validateAlbertaPostalCode(postalCode) {
const formatted = postalCode.replace(/\s/g, '').toUpperCase();
return formatted.startsWith('T') && validatePostalCode(postalCode);
}
// Validate email format
function validateEmail(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
module.exports = {
validatePostalCode,
validateAlbertaPostalCode,
validateEmail
};
3. Frontend Implementation
app/public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact Your Alberta Representatives</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<div class="container">
<header>
<h1>Contact Your Alberta Representatives</h1>
<p>Enter your postal code to find and email your elected representatives</p>
</header>
<main>
<!-- Postal Code Form -->
<section id="postal-form-section">
<form id="postal-form">
<div class="form-group">
<label for="postal-code">Alberta Postal Code:</label>
<input
type="text"
id="postal-code"
placeholder="e.g., T5K 2J1"
pattern="[Tt]\d[A-Za-z]\s?\d[A-Za-z]\d"
maxlength="7"
required
>
<button type="submit" class="btn btn-primary">
Find My Representatives
</button>
</div>
<small class="help-text">Alberta postal codes start with 'T'</small>
</form>
</section>
<!-- Loading Spinner -->
<div id="loading" class="loading hidden">
<div class="spinner"></div>
<p>Finding your representatives...</p>
</div>
<!-- Error Messages -->
<div id="error-message" class="error-message hidden"></div>
<!-- Representatives List -->
<section id="representatives-section" class="hidden">
<h2>Your Representatives</h2>
<div id="federal-reps" class="rep-category hidden">
<h3>Federal Representatives (MPs)</h3>
<div class="rep-cards"></div>
</div>
<div id="provincial-reps" class="rep-category hidden">
<h3>Provincial Representatives (MLAs)</h3>
<div class="rep-cards"></div>
</div>
<div id="municipal-reps" class="rep-category hidden">
<h3>Municipal Representatives</h3>
<div class="rep-cards"></div>
</div>
</section>
</main>
<!-- Email Modal -->
<div id="email-modal" class="modal hidden">
<div class="modal-content">
<span class="close">×</span>
<h2>Send Email to <span id="rep-name"></span></h2>
<form id="email-form">
<input type="hidden" id="rep-email">
<input type="hidden" id="rep-full-name">
<div class="form-group">
<label for="from-email">Your Email:</label>
<input
type="email"
id="from-email"
required
placeholder="your.email@example.com"
>
</div>
<div class="form-group">
<label for="email-subject">Subject:</label>
<input
type="text"
id="email-subject"
required
value="Message from your constituent"
maxlength="200"
>
</div>
<div class="form-group">
<label for="email-message">Message:</label>
<textarea
id="email-message"
rows="10"
required
minlength="10"
maxlength="5000"
placeholder="Write your message here..."
></textarea>
<small class="char-count">0 / 5000 characters</small>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary cancel-btn">Cancel</button>
<button type="submit" class="btn btn-primary">Send Email</button>
</div>
</form>
<div id="email-status" class="status-message hidden"></div>
</div>
</div>
</div>
<script src="js/api-client.js"></script>
<script src="js/postal-lookup.js"></script>
<script src="js/main.js"></script>
</body>
</html>
app/public/css/styles.css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
margin-bottom: 40px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
header h1 {
color: #005a9c;
margin-bottom: 10px;
}
/* Forms */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #555;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #005a9c;
box-shadow: 0 0 0 2px rgba(0, 90, 156, 0.1);
}
#postal-form {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
max-width: 500px;
margin: 0 auto;
}
#postal-form .form-group {
display: flex;
gap: 10px;
}
#postal-form input {
flex: 1;
}
/* Buttons */
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background-color: #005a9c;
color: white;
}
.btn-primary:hover {
background-color: #004a7c;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
/* Loading Spinner */
.loading {
text-align: center;
padding: 40px;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #005a9c;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Error Messages */
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
border: 1px solid #f5c6cb;
}
/* Representatives Section */
#representatives-section {
margin-top: 40px;
}
.rep-category {
margin-bottom: 40px;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.rep-category h3 {
color: #005a9c;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #e9ecef;
}
.rep-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.rep-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
background: #f8f9fa;
transition: transform 0.2s, box-shadow 0.2s;
}
.rep-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.rep-card h4 {
color: #005a9c;
margin-bottom: 10px;
}
.rep-card .rep-info {
margin-bottom: 10px;
color: #666;
}
.rep-card .rep-info strong {
color: #333;
}
.rep-card .contact-info {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #ddd;
}
.rep-card .no-email {
color: #6c757d;
font-style: italic;
}
/* Modal */
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background-color: white;
padding: 30px;
border-radius: 8px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.close {
position: absolute;
right: 20px;
top: 20px;
font-size: 28px;
font-weight: bold;
cursor: pointer;
color: #aaa;
}
.close:hover {
color: #000;
}
.modal h2 {
color: #005a9c;
margin-bottom: 20px;
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.char-count {
display: block;
text-align: right;
color: #6c757d;
font-size: 14px;
margin-top: 5px;
}
.status-message {
padding: 15px;
border-radius: 4px;
margin-top: 20px;
}
.status-message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
/* Utility Classes */
.hidden {
display: none !important;
}
.help-text {
color: #6c757d;
font-size: 14px;
margin-top: 5px;
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.container {
padding: 10px;
}
#postal-form .form-group {
flex-direction: column;
}
.rep-cards {
grid-template-columns: 1fr;
}
.modal-content {
padding: 20px;
width: 95%;
}
.form-actions {
flex-direction: column;
}
.form-actions .btn {
width: 100%;
}
}