mirror of
https://github.com/MvDevsUnion/WPetition.git
synced 2026-05-06 06:53:14 +00:00
create petitions
added support to create petitions from the website
This commit is contained in:
@@ -5,7 +5,8 @@
|
||||
"Bash(docker compose:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(./ci-helper:*)"
|
||||
"Bash(./ci-helper:*)",
|
||||
"Bash(npm run build:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# WPetition
|
||||
|
||||
a very basic and simple frontend to show how easy it is to setup the ui for this
|
||||
|
||||
```
|
||||
baseUrl: 'http://localhost:5299'
|
||||
```
|
||||
make sure to edit this for prod pls or it wont work
|
||||
Binary file not shown.
@@ -1,701 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Petition Details</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||
<link rel="preconnect" href="https://challenges.cloudflare.com">
|
||||
<script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
||||
async
|
||||
defer></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="loading" class="loading">Loading petition...</div>
|
||||
<div id="error" class="error" style="display: none;"></div>
|
||||
<div id="petition-content" style="display: none;">
|
||||
<div class="lang-switcher">
|
||||
<button id="lang-en" class="lang-btn active">English</button>
|
||||
<button id="lang-dv" class="lang-btn">ދިވެހި</button>
|
||||
</div>
|
||||
|
||||
<div class="petition-header">
|
||||
<h1 id="petition-name-eng" class="lang-en-content"></h1>
|
||||
<h2 id="petition-name-dhiv" class="dhivehi lang-dv-content"></h2>
|
||||
<div class="metadata">
|
||||
<span class="start-date">Start Date: <span id="start-date"></span></span>
|
||||
<span class="signature-count">Signatures: <span id="signature-count"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="author-details">
|
||||
<h3 class="lang-en-content">Author Details</h3>
|
||||
<h3 class="dhivehi lang-dv-content">ލިޔުންތެރިގެ މައުލޫމާތު</h3>
|
||||
<p><strong class="lang-en-content">Name:</strong><strong class="dhivehi lang-dv-content">ނަން:</strong> <span id="author-name"></span></p>
|
||||
<!-- <p><strong class="lang-en-content">NID:</strong><strong class="dhivehi lang-dv-content">ކާޑު ނަންބަރު:</strong> <span id="author-nid"></span></p> -->
|
||||
</div>
|
||||
|
||||
<div class="petition-body">
|
||||
<div class="lang-en-content">
|
||||
<div id="petition-body-eng" class="body-content"></div>
|
||||
</div>
|
||||
|
||||
<div class="lang-dv-content">
|
||||
<div id="petition-body-dhiv" class="body-content dhivehi"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="signature-section">
|
||||
<h3 class="lang-en-content">Sign this Petition</h3>
|
||||
<h3 class="dhivehi lang-dv-content">މި މައްސަލައިގައި ސޮއި ކުރައްވާ</h3>
|
||||
|
||||
<form id="signature-form">
|
||||
<div class="form-group">
|
||||
<label class="lang-en-content">Full Name (As on ID card)</label>
|
||||
<label class="dhivehi lang-dv-content">ފުރިހަމަ ނަން</label>
|
||||
<input type="text" id="name" name="name" minlength="3" maxlength="30" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group idcard-group">
|
||||
<label class="lang-en-content">ID Card Number</label>
|
||||
<label class="dhivehi lang-dv-content">ކާޑު ނަންބަރު</label>
|
||||
<div class="idcard-input">
|
||||
<span class="idcard-prefix">A</span>
|
||||
<input type="tel" inputmode="numeric" pattern="\d{6}" id="idCard" name="idCard" maxlength="6" required placeholder="123456" aria-label="ID number, 6 digits" dir="ltr">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="lang-en-content">Signature</label>
|
||||
<label class="dhivehi lang-dv-content">ސޮއި</label>
|
||||
<div class="signature-pad-container">
|
||||
<canvas id="signature-pad" width="600" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group consent-group">
|
||||
<label class="consent-label">
|
||||
<input type="checkbox" id="confirm-consent" name="confirmConsent" required>
|
||||
<span class="lang-en-content">I acknowledge my signature and information will be sent to the Parliament Petition Committee, and that the information I provide is true.</span>
|
||||
<span class="dhivehi lang-dv-content">I acknowledge my signature and information will be sent to the Parliament Petition Committee, and that the information I provide is true.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="form-message" class="form-message"></div>
|
||||
|
||||
<!-- Cloudflare Turnstile: add callbacks so auto-render uses our handlers -->
|
||||
<div class="cf-turnstile" data-sitekey="0x4AAAAAACHH4QC3wIhkCuhd"
|
||||
data-callback="onTurnstileSuccess"
|
||||
data-error-callback="onTurnstileError"
|
||||
data-expired-callback="onTurnstileExpired"></div>
|
||||
|
||||
<div class="form-buttons">
|
||||
<button type="button" id="clear-signature" class="btn-secondary" title="Clear signature" aria-label="Clear signature">
|
||||
<i class="fas fa-eraser"></i> Clear
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" title="Submit signature" aria-label="Submit signature">
|
||||
<i class="fas fa-paper-plane"></i> Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tweet Prompt Modal -->
|
||||
<div id="tweet-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-modal">×</span>
|
||||
<h2 id="modal-title">Share this petition!</h2>
|
||||
<p id="modal-text">Help spread the word by tweeting about this petition.</p>
|
||||
<div class="modal-buttons">
|
||||
<button id="tweet-button" class="btn-primary">
|
||||
<i class="fab fa-twitter"></i> <span id="tweet-btn-text">Tweet Now</span>
|
||||
</button>
|
||||
<button id="skip-tweet" class="btn-secondary"><span id="skip-btn-text">Maybe Later</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API Configuration - Change this for different environments
|
||||
const API_CONFIG = {
|
||||
baseUrl: '' // Empty for same-origin requests (Nginx proxies /api/* to backend)
|
||||
};
|
||||
|
||||
let currentLang = 'en';
|
||||
|
||||
// Extract petition ID from URL query parameter
|
||||
function getPetitionIdFromUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const id = urlParams.get('id');
|
||||
|
||||
// Hardcoded mapping for cleaner URLs
|
||||
if (id === 'dataprotection') {
|
||||
return '7a315446-3c63-40cc-b382-86152bd8e591';
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// Get language from URL query parameter
|
||||
function getLangFromUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const lang = urlParams.get('lang');
|
||||
return lang === 'dv' ? 'dv' : 'en';
|
||||
}
|
||||
|
||||
// Update URL with current language
|
||||
function updateUrlWithLang(lang) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (lang === 'en') {
|
||||
urlParams.delete('lang');
|
||||
} else {
|
||||
urlParams.set('lang', lang);
|
||||
}
|
||||
const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '');
|
||||
window.history.pushState({}, '', newUrl);
|
||||
}
|
||||
|
||||
// Switch language
|
||||
function switchLanguage(lang) {
|
||||
currentLang = lang;
|
||||
|
||||
// Update button states
|
||||
document.getElementById('lang-en').classList.toggle('active', lang === 'en');
|
||||
document.getElementById('lang-dv').classList.toggle('active', lang === 'dv');
|
||||
|
||||
// Show/hide content based on language
|
||||
const enContent = document.querySelectorAll('.lang-en-content');
|
||||
const dvContent = document.querySelectorAll('.lang-dv-content');
|
||||
|
||||
enContent.forEach(el => {
|
||||
el.style.display = lang === 'en' ? '' : 'none';
|
||||
});
|
||||
|
||||
dvContent.forEach(el => {
|
||||
el.style.display = lang === 'dv' ? '' : 'none';
|
||||
});
|
||||
|
||||
// Update URL
|
||||
updateUrlWithLang(lang);
|
||||
// Set document direction for RTL languages (Dhivehi uses RTL script)
|
||||
try {
|
||||
document.documentElement.dir = (lang === 'dv') ? 'rtl' : 'ltr';
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// Fetch petition data
|
||||
async function fetchPetition(petitionId) {
|
||||
const apiUrl = `${API_CONFIG.baseUrl}/api/Sign/petition/${petitionId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
displayPetition(data);
|
||||
} catch (error) {
|
||||
// If fetching fails, show a visible dev notice and load dummy data
|
||||
console.warn('Failed to fetch petition, falling back to dummy data.', error);
|
||||
const errorDiv = document.getElementById('error');
|
||||
errorDiv.textContent = 'Failed to load petition from server — showing dummy data for development.';
|
||||
errorDiv.style.display = 'block';
|
||||
displayPetition(getDummyPetition(petitionId));
|
||||
}
|
||||
}
|
||||
|
||||
// Return a dummy petition object useful for local development
|
||||
function getDummyPetition(petitionId) {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: petitionId || 'dev-petition',
|
||||
nameEng: 'Demo Petition: Improve Local Services',
|
||||
nameDhiv: 'Demo Petition',
|
||||
startDate: now.toLocaleDateString(),
|
||||
signatureCount: 42,
|
||||
authorDetails: {
|
||||
name: 'Demo Author'
|
||||
},
|
||||
petitionBodyEng: 'This is dummy petition content to enable local development. Replace with real data when the API is available.',
|
||||
petitionBodyDhiv: 'Demo petition content (Dhivehi)'
|
||||
};
|
||||
}
|
||||
|
||||
// Display petition data
|
||||
function displayPetition(data) {
|
||||
// Store petition data for tweet generation
|
||||
currentPetitionData = data;
|
||||
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('petition-content').style.display = 'block';
|
||||
|
||||
document.getElementById('petition-name-eng').textContent = data.nameEng;
|
||||
document.getElementById('petition-name-dhiv').textContent = data.nameDhiv;
|
||||
document.getElementById('start-date').textContent = data.startDate;
|
||||
document.getElementById('signature-count').textContent = data.signatureCount;
|
||||
|
||||
document.getElementById('author-name').textContent = data.authorDetails.name;
|
||||
//document.getElementById('author-nid').textContent = data.authorDetails.nid;
|
||||
|
||||
// Convert markdown-style formatting to HTML (basic support)
|
||||
document.getElementById('petition-body-eng').innerHTML = formatText(data.petitionBodyEng);
|
||||
document.getElementById('petition-body-dhiv').innerHTML = formatText(data.petitionBodyDhiv);
|
||||
|
||||
// Set initial language display
|
||||
switchLanguage(currentLang);
|
||||
}
|
||||
|
||||
// Parse Markdown to HTML
|
||||
function formatText(text) {
|
||||
return marked.parse(text);
|
||||
}
|
||||
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
const errorDiv = document.getElementById('error');
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
// Signature pad functionality
|
||||
let isDrawing = false;
|
||||
let signaturePaths = [];
|
||||
let currentPath = [];
|
||||
// Turnstile widget state
|
||||
let turnstileWidgetId = null;
|
||||
let turnstileToken = null;
|
||||
|
||||
function onTurnstileSuccess(token) {
|
||||
turnstileToken = token;
|
||||
}
|
||||
|
||||
function onTurnstileError() {
|
||||
turnstileToken = null;
|
||||
}
|
||||
|
||||
function onTurnstileExpired() {
|
||||
turnstileToken = null;
|
||||
}
|
||||
|
||||
// Try to render Turnstile widget when API is available
|
||||
function ensureTurnstileRendered() {
|
||||
const container = document.querySelector('.cf-turnstile');
|
||||
if (!container) return;
|
||||
// If Turnstile already auto-rendered an iframe inside the container, don't render again
|
||||
if (container.children.length > 0) {
|
||||
// mark as auto-rendered
|
||||
turnstileWidgetId = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.turnstile && turnstileWidgetId === null) {
|
||||
try {
|
||||
turnstileWidgetId = turnstile.render(container, {
|
||||
sitekey: container.getAttribute('data-sitekey'),
|
||||
callback: onTurnstileSuccess,
|
||||
'error-callback': onTurnstileError,
|
||||
'expired-callback': onTurnstileExpired
|
||||
});
|
||||
} catch (e) {
|
||||
// If render fails, leave the element as-is (script may auto-render)
|
||||
}
|
||||
} else if (!window.turnstile) {
|
||||
// Retry until turnstile script is available
|
||||
setTimeout(ensureTurnstileRendered, 200);
|
||||
}
|
||||
}
|
||||
|
||||
function initSignaturePad() {
|
||||
const canvas = document.getElementById('signature-pad');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Resize canvas to match its display size (fixes coordinate offset bug)
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
}
|
||||
|
||||
// Re-apply context settings after resize
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// Mouse events
|
||||
canvas.addEventListener('mousedown', startDrawing);
|
||||
canvas.addEventListener('mousemove', draw);
|
||||
canvas.addEventListener('mouseup', stopDrawing);
|
||||
canvas.addEventListener('mouseout', stopDrawing);
|
||||
|
||||
// Touch events
|
||||
canvas.addEventListener('touchstart', handleTouchStart);
|
||||
canvas.addEventListener('touchmove', handleTouchMove);
|
||||
canvas.addEventListener('touchend', stopDrawing);
|
||||
|
||||
// Clear button
|
||||
document.getElementById('clear-signature').addEventListener('click', clearSignature);
|
||||
}
|
||||
|
||||
function getMousePos(canvas, evt) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: evt.clientX - rect.left,
|
||||
y: evt.clientY - rect.top
|
||||
};
|
||||
}
|
||||
|
||||
function getTouchPos(canvas, evt) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: evt.touches[0].clientX - rect.left,
|
||||
y: evt.touches[0].clientY - rect.top
|
||||
};
|
||||
}
|
||||
|
||||
function startDrawing(e) {
|
||||
isDrawing = true;
|
||||
const canvas = document.getElementById('signature-pad');
|
||||
const pos = getMousePos(canvas, e);
|
||||
currentPath = [pos];
|
||||
}
|
||||
|
||||
function draw(e) {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const canvas = document.getElementById('signature-pad');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const pos = getMousePos(canvas, e);
|
||||
|
||||
currentPath.push(pos);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(currentPath[currentPath.length - 2].x, currentPath[currentPath.length - 2].y);
|
||||
ctx.lineTo(pos.x, pos.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function stopDrawing() {
|
||||
if (isDrawing && currentPath.length > 0) {
|
||||
signaturePaths.push([...currentPath]);
|
||||
currentPath = [];
|
||||
}
|
||||
isDrawing = false;
|
||||
}
|
||||
|
||||
function handleTouchStart(e) {
|
||||
e.preventDefault();
|
||||
isDrawing = true;
|
||||
const canvas = document.getElementById('signature-pad');
|
||||
const pos = getTouchPos(canvas, e);
|
||||
currentPath = [pos];
|
||||
}
|
||||
|
||||
function handleTouchMove(e) {
|
||||
e.preventDefault();
|
||||
if (!isDrawing) return;
|
||||
|
||||
const canvas = document.getElementById('signature-pad');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const pos = getTouchPos(canvas, e);
|
||||
|
||||
currentPath.push(pos);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(currentPath[currentPath.length - 2].x, currentPath[currentPath.length - 2].y);
|
||||
ctx.lineTo(pos.x, pos.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function clearSignature() {
|
||||
const canvas = document.getElementById('signature-pad');
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
signaturePaths = [];
|
||||
currentPath = [];
|
||||
}
|
||||
|
||||
function generateSVG() {
|
||||
if (signaturePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pathData = '';
|
||||
signaturePaths.forEach(path => {
|
||||
if (path.length > 0) {
|
||||
pathData += `M ${path[0].x} ${path[0].y} `;
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
pathData += `L ${path[i].x} ${path[i].y} `;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const svg = `<svg width="600" height="200" xmlns="http://www.w3.org/2000/svg"><path d="${pathData}" stroke="black" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||
return svg;
|
||||
}
|
||||
|
||||
// Form submission
|
||||
async function submitSignature(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const name = document.getElementById('name').value;
|
||||
const idCardDigits = document.getElementById('idCard').value;
|
||||
const idCardPattern = /^\d{6}$/;
|
||||
const signature = generateSVG();
|
||||
|
||||
if (!signature) {
|
||||
showFormMessage('Please provide your signature.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the signer has confirmed consent/accuracy
|
||||
const consentElem = document.getElementById('confirm-consent');
|
||||
const consentChecked = consentElem ? consentElem.checked : true;
|
||||
if (!consentChecked) {
|
||||
showFormMessage('Please confirm that your submission will be sent to the Parliament Petition Committee and that your information is true.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate ID digits (6 digits) and assemble full ID with prefix 'A'
|
||||
if (!idCardPattern.test(idCardDigits)) {
|
||||
showFormMessage('Please enter your ID number as 6 digits (numbers only).', 'error');
|
||||
return;
|
||||
}
|
||||
const idCard = 'A' + idCardDigits;
|
||||
|
||||
const petitionId = getPetitionIdFromUrl();
|
||||
const apiUrl = `${API_CONFIG.baseUrl}/api/Sign/petition/${petitionId}`;
|
||||
|
||||
// Ensure Turnstile widget is rendered
|
||||
ensureTurnstileRendered();
|
||||
|
||||
// Obtain token: prefer the callback value, fall back to getResponse or execute
|
||||
let token = turnstileToken;
|
||||
if ((!token || token.length === 0) && window.turnstile && turnstileWidgetId !== null) {
|
||||
try {
|
||||
if (typeof turnstile.getResponse === 'function') {
|
||||
let resp = null;
|
||||
try {
|
||||
if (turnstileWidgetId !== null && turnstileWidgetId >= 0) {
|
||||
resp = turnstile.getResponse(turnstileWidgetId);
|
||||
} else {
|
||||
// try without id (auto-rendered widgets may be retrievable this way)
|
||||
resp = turnstile.getResponse();
|
||||
}
|
||||
} catch (e) {
|
||||
resp = null;
|
||||
}
|
||||
if (resp) token = resp;
|
||||
}
|
||||
|
||||
if ((!token || token.length === 0) && typeof turnstile.execute === 'function') {
|
||||
// Trigger challenge; wait up to ~5s for callback to set token
|
||||
try { turnstile.execute(turnstileWidgetId); } catch (err) { }
|
||||
token = await new Promise((resolve) => {
|
||||
let attempts = 0;
|
||||
const interval = setInterval(() => {
|
||||
attempts++;
|
||||
if (turnstileToken) {
|
||||
clearInterval(interval);
|
||||
resolve(turnstileToken);
|
||||
} else if (attempts > 25) { // ~5 seconds
|
||||
clearInterval(interval);
|
||||
resolve(null);
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
token = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
showFormMessage('Please complete the captcha challenge.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
idCard: idCard,
|
||||
signature: signature,
|
||||
turnstileToken: token
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
showFormMessage('Signature submitted successfully!', 'success');
|
||||
|
||||
// Show tweet prompt modal
|
||||
showTweetModal();
|
||||
|
||||
document.getElementById('signature-form').reset();
|
||||
clearSignature();
|
||||
|
||||
// Refresh petition data to update signature count
|
||||
setTimeout(() => {
|
||||
fetchPetition(petitionId);
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
showFormMessage(`Failed to submit signature: ${error.message}`, 'error');
|
||||
} finally {
|
||||
// Reset the Turnstile widget so a fresh token is required next time
|
||||
try {
|
||||
if (window.turnstile && turnstileWidgetId !== null) {
|
||||
turnstile.reset(turnstileWidgetId);
|
||||
}
|
||||
} catch (e) { }
|
||||
turnstileToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showFormMessage(message, type) {
|
||||
const messageDiv = document.getElementById('form-message');
|
||||
messageDiv.textContent = message;
|
||||
messageDiv.className = `form-message ${type}`;
|
||||
messageDiv.style.display = 'block';
|
||||
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Tweet Modal Functions
|
||||
let currentPetitionData = null;
|
||||
|
||||
function updateModalLanguage() {
|
||||
const translations = {
|
||||
en: {
|
||||
title: 'Share this petition!',
|
||||
text: 'Help spread the word by tweeting about this petition.',
|
||||
tweetBtn: 'Tweet Now',
|
||||
skipBtn: 'Maybe Later'
|
||||
},
|
||||
dv: {
|
||||
title: 'Share this petition!',
|
||||
text: 'Help spread the word by tweeting about this petition.',
|
||||
tweetBtn: 'Tweet Now',
|
||||
skipBtn: 'Maybe Later'
|
||||
}
|
||||
};
|
||||
|
||||
const t = translations[currentLang];
|
||||
|
||||
document.getElementById('modal-title').textContent = t.title;
|
||||
document.getElementById('modal-text').textContent = t.text;
|
||||
document.getElementById('tweet-btn-text').textContent = t.tweetBtn;
|
||||
document.getElementById('skip-btn-text').textContent = t.skipBtn;
|
||||
}
|
||||
|
||||
function showTweetModal() {
|
||||
updateModalLanguage();
|
||||
const modal = document.getElementById('tweet-modal');
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
function hideTweetModal() {
|
||||
const modal = document.getElementById('tweet-modal');
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
|
||||
function generateTweetText() {
|
||||
if (!currentPetitionData) return '';
|
||||
|
||||
const petitionName = currentLang === 'dv' ? currentPetitionData.nameDhiv : currentPetitionData.nameEng;
|
||||
const petitionUrl = `${window.location.origin}${window.location.pathname}?id=${currentPetitionData.id}`;
|
||||
|
||||
return `I just signed "${petitionName}"! \n\nAdd your signature: ${petitionUrl}`;
|
||||
}
|
||||
|
||||
function openTwitterIntent() {
|
||||
const tweetText = generateTweetText();
|
||||
const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}`;
|
||||
console.log(twitterUrl);
|
||||
window.open(twitterUrl, '_blank');
|
||||
hideTweetModal();
|
||||
}
|
||||
|
||||
function setupTweetModalListeners() {
|
||||
const modal = document.getElementById('tweet-modal');
|
||||
const tweetButton = document.getElementById('tweet-button');
|
||||
const skipButton = document.getElementById('skip-tweet');
|
||||
const closeButton = document.querySelector('.close-modal');
|
||||
|
||||
// Tweet button click
|
||||
tweetButton.addEventListener('click', openTwitterIntent);
|
||||
|
||||
// Skip button click
|
||||
skipButton.addEventListener('click', hideTweetModal);
|
||||
|
||||
// Close X click
|
||||
closeButton.addEventListener('click', hideTweetModal);
|
||||
|
||||
// Click outside modal
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
hideTweetModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const petitionId = getPetitionIdFromUrl();
|
||||
|
||||
if (!petitionId) {
|
||||
showError('No petition ID found in URL. Please provide a valid petition URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get initial language from URL
|
||||
currentLang = getLangFromUrl();
|
||||
|
||||
// Set up language switcher event listeners
|
||||
document.getElementById('lang-en').addEventListener('click', () => switchLanguage('en'));
|
||||
document.getElementById('lang-dv').addEventListener('click', () => switchLanguage('dv'));
|
||||
|
||||
// Initialize signature pad
|
||||
initSignaturePad();
|
||||
// Render Turnstile (retry until script available)
|
||||
ensureTurnstileRendered();
|
||||
|
||||
// Set up form submission
|
||||
document.getElementById('signature-form').addEventListener('submit', submitSignature);
|
||||
|
||||
// Set up tweet modal listeners
|
||||
setupTweetModalListeners();
|
||||
|
||||
// Make clicking the 'A' prefix focus the ID input
|
||||
document.querySelectorAll('.idcard-prefix').forEach(prefix => {
|
||||
prefix.addEventListener('click', () => {
|
||||
const input = prefix.parentElement.querySelector('input[id="idCard"]');
|
||||
if (input) input.focus();
|
||||
});
|
||||
});
|
||||
|
||||
fetchPetition(petitionId);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,496 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'Utheem';
|
||||
src: url('fonts/utheem.woff') format('woff'),
|
||||
url('fonts/utheem.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Shangu';
|
||||
src: url('fonts/shangu.woff') format('woff'),
|
||||
url('fonts/shangu.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.lang-switcher {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
padding: 8px 20px;
|
||||
border: 2px solid #007bff;
|
||||
background-color: white;
|
||||
color: #007bff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.lang-btn:hover {
|
||||
background-color: #f0f8ff;
|
||||
}
|
||||
|
||||
.lang-btn.active {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.lang-btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #fee;
|
||||
border: 1px solid #fcc;
|
||||
color: #c33;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.petition-header {
|
||||
border-bottom: 2px solid #007bff;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.petition-header h1 {
|
||||
font-size: 32px;
|
||||
color: #222;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.petition-header h2 {
|
||||
font-size: 24px;
|
||||
color: #555;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.petition-header h2.dhivehi {
|
||||
font-family: 'Shangu', 'Faruma', 'MV Faseyha', 'Waheed', 'Noto Sans Thaana', sans-serif;
|
||||
}
|
||||
|
||||
.dhivehi {
|
||||
font-family: 'Utheem', 'Faruma', 'MV Faseyha', 'Waheed', 'Noto Sans Thaana', sans-serif;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 15px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.metadata span {
|
||||
background-color: #f0f0f0;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.metadata span span {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.author-details {
|
||||
background-color: #f9f9f9;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.author-details h3 {
|
||||
font-size: 18px;
|
||||
color: #007bff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.author-details p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.petition-body {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.petition-body h3 {
|
||||
font-size: 20px;
|
||||
color: #007bff;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.body-content {
|
||||
padding: 15px;
|
||||
background-color: #fafafa;
|
||||
border-radius: 4px;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.body-content strong {
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.signature-count {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.signature-count span {
|
||||
color: #007bff !important;
|
||||
}
|
||||
|
||||
.signature-section {
|
||||
margin-top: 40px;
|
||||
padding-top: 30px;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.signature-section h3 {
|
||||
font-size: 24px;
|
||||
color: #007bff;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
/* ID card input: prefix + numeric field */
|
||||
.idcard-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
max-width: 260px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.idcard-prefix {
|
||||
padding: 10px 12px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
border: none; /* prefix no longer has its own border */
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.idcard-input input[type="tel"] {
|
||||
padding: 10px;
|
||||
width: 120px;
|
||||
font-size: 16px;
|
||||
text-align: left;
|
||||
border: none; /* input no longer has its own border */
|
||||
outline: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* RTL adjustments: reverse order but keep the wrapper border intact */
|
||||
:dir(rtl) .idcard-input {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
:dir(rtl) .idcard-input input[type="tel"] {
|
||||
text-align: left; /* keep digits LTR within RTL page */
|
||||
}
|
||||
|
||||
/* Highlight wrapper on focus */
|
||||
.idcard-input:focus-within {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 4px rgba(0, 123, 255, 0.08);
|
||||
}
|
||||
|
||||
.signature-pad-container {
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background-color: white;
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#signature-pad {
|
||||
display: block;
|
||||
touch-action: none;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 3;
|
||||
}
|
||||
|
||||
.signature-actions {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
|
||||
.form-message {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 15px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-message.success {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.form-message.error {
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.petition-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.petition-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-buttons {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-buttons button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tweet Modal Styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.close-modal {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 20px;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.close-modal:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
font-size: 24px;
|
||||
color: #222;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 25px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-buttons button {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-buttons .btn-primary {
|
||||
background-color: #1DA1F2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-buttons .btn-primary:hover {
|
||||
background-color: #1a91da;
|
||||
}
|
||||
|
||||
.modal-buttons .btn-primary i {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(-50px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal-content {
|
||||
padding: 30px 20px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
54
frontend-react/package-lock.json
generated
54
frontend-react/package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"marked": "^17.0.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
@@ -2804,6 +2805,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -4163,6 +4176,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
||||
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
|
||||
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
|
||||
"dependencies": {
|
||||
"react-router": "7.13.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-signature-canvas": {
|
||||
"version": "1.1.0-alpha.2",
|
||||
"resolved": "https://registry.npmjs.org/react-signature-canvas/-/react-signature-canvas-1.1.0-alpha.2.tgz",
|
||||
@@ -4286,6 +4335,11 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"marked": "^17.0.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
|
||||
@@ -1,127 +1,17 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { usePetition } from "@/hooks/usePetition";
|
||||
import { useLanguage } from "@/hooks/useLanguage";
|
||||
import { submitSignature } from "@/lib/api";
|
||||
import { LanguageSwitcher } from "@/components/layout/LanguageSwitcher";
|
||||
import { LoadingState } from "@/components/layout/LoadingState";
|
||||
import { ErrorState } from "@/components/layout/ErrorState";
|
||||
import { PetitionHeader } from "@/components/petition/PetitionHeader";
|
||||
import { AuthorCard } from "@/components/petition/AuthorCard";
|
||||
import { PetitionBody } from "@/components/petition/PetitionBody";
|
||||
import { SignatureForm } from "@/components/signature/SignatureForm";
|
||||
import { TweetModal } from "@/components/TweetModal";
|
||||
import { PenLine } from "lucide-react";
|
||||
|
||||
function getPetitionIdFromUrl(): string | null {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const id = urlParams.get("id");
|
||||
|
||||
return id;
|
||||
}
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { HomePage } from "@/pages/HomePage";
|
||||
import { PetitionPage } from "@/pages/PetitionPage";
|
||||
import { CreatePetitionPage } from "@/pages/CreatePetitionPage";
|
||||
|
||||
function App() {
|
||||
const petitionId = getPetitionIdFromUrl();
|
||||
const { petition, loading, error, refetch } = usePetition(petitionId);
|
||||
const { language, setLanguage } = useLanguage();
|
||||
const [showTweetModal, setShowTweetModal] = useState(false);
|
||||
const signatureFormRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToSignForm = () => {
|
||||
signatureFormRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: {
|
||||
name: string;
|
||||
idCard: string;
|
||||
signature: string;
|
||||
turnstileToken: string;
|
||||
}) => {
|
||||
if (!petitionId) throw new Error("No petition ID");
|
||||
|
||||
await submitSignature(petitionId, data);
|
||||
|
||||
// Show tweet modal after successful submission
|
||||
setShowTweetModal(true);
|
||||
|
||||
// Refresh petition data to update signature count
|
||||
setTimeout(() => {
|
||||
refetch();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// No petition ID in URL
|
||||
if (!petitionId) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-5">
|
||||
<div className="max-w-4xl mx-auto bg-card rounded-lg shadow-lg p-10">
|
||||
<ErrorState message="No petition ID found in URL. Please provide a valid petition URL." />
|
||||
</div>
|
||||
<footer className="text-center text-slate-500 text-sm mt-6 pb-4">
|
||||
Powered by Mv Devs Union
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50/50 p-4 md:p-8 font-sans">
|
||||
<div className="max-w-3xl mx-auto bg-white rounded-xl shadow-sm border border-slate-100 p-6 md:p-10 animate-in fade-in duration-500 slide-in-from-bottom-4">
|
||||
{loading ? (
|
||||
<div className="min-h-[400px] flex flex-col justify-center">
|
||||
<LoadingState language={language} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
{petition && (
|
||||
<div className="space-y-8">
|
||||
<LanguageSwitcher
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
/>
|
||||
|
||||
<PetitionHeader petition={petition} language={language} />
|
||||
|
||||
<button
|
||||
onClick={scrollToSignForm}
|
||||
className={`w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors ${language === "dv" ? "flex-row-reverse dhivehi" : ""}`}
|
||||
>
|
||||
<PenLine className="w-5 h-5" />
|
||||
{language === "dv" ? "މިހާރު ސޮއި ކުރައްވާ" : "Sign Now"}
|
||||
</button>
|
||||
|
||||
<AuthorCard
|
||||
author={petition.authorDetails}
|
||||
language={language}
|
||||
/>
|
||||
|
||||
<PetitionBody
|
||||
bodyEng={petition.petitionBodyEng}
|
||||
bodyDhiv={petition.petitionBodyDhiv}
|
||||
language={language}
|
||||
/>
|
||||
|
||||
<div ref={signatureFormRef}>
|
||||
<SignatureForm language={language} onSubmit={handleSubmit} />
|
||||
</div>
|
||||
|
||||
<TweetModal
|
||||
open={showTweetModal}
|
||||
onClose={() => setShowTweetModal(false)}
|
||||
petition={petition}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="text-center text-slate-500 text-sm mt-6 pb-4">
|
||||
Powered by Mv Devs Union
|
||||
</footer>
|
||||
</div>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/Petition/:slug" element={<PetitionPage />} />
|
||||
<Route path="/CreatePetition" element={<CreatePetitionPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { PetitionDetails } from "@/types/petition";
|
||||
import { fetchPetition, getDummyPetition } from "@/lib/api";
|
||||
import { fetchPetitionBySlug, getDummyPetition } from "@/lib/api";
|
||||
|
||||
interface UsePetitionResult {
|
||||
petition: PetitionDetails | null;
|
||||
@@ -9,15 +9,15 @@ interface UsePetitionResult {
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function usePetition(petitionId: string | null): UsePetitionResult {
|
||||
export function usePetition(slug: string | null): UsePetitionResult {
|
||||
const [petition, setPetition] = useState<PetitionDetails | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadPetition = useCallback(async () => {
|
||||
if (!petitionId) {
|
||||
if (!slug) {
|
||||
setLoading(false);
|
||||
setError("No petition ID provided");
|
||||
setError("No petition slug provided");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function usePetition(petitionId: string | null): UsePetitionResult {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchPetition(petitionId);
|
||||
const data = await fetchPetitionBySlug(slug);
|
||||
setPetition(data);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
@@ -36,11 +36,11 @@ export function usePetition(petitionId: string | null): UsePetitionResult {
|
||||
setError(
|
||||
"Failed to load petition from server — showing dummy data for development.",
|
||||
);
|
||||
setPetition(getDummyPetition(petitionId));
|
||||
setPetition(getDummyPetition(slug));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [petitionId]);
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPetition();
|
||||
|
||||
@@ -17,6 +17,69 @@ export async function fetchPetition(
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchPetitionBySlug(
|
||||
slug: string,
|
||||
): Promise<PetitionDetails> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/Sign/petition/by-slug/${slug}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export interface PetitionFormData {
|
||||
slug: string;
|
||||
nameDhiv: string;
|
||||
nameEng: string;
|
||||
startDate: string; // dd-MM-yyyy
|
||||
authorName: string;
|
||||
authorNid: string;
|
||||
petitionBodyDhiv: string;
|
||||
petitionBodyEng: string;
|
||||
}
|
||||
|
||||
export interface SubmitPetitionResponse {
|
||||
message: string;
|
||||
petitionId: string;
|
||||
slug: string;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
authorId: string;
|
||||
}
|
||||
|
||||
export async function submitPetition(
|
||||
data: PetitionFormData,
|
||||
): Promise<SubmitPetitionResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("Slug", data.slug);
|
||||
formData.append("NameDhiv", data.nameDhiv);
|
||||
formData.append("NameEng", data.nameEng);
|
||||
formData.append("StartDate", data.startDate);
|
||||
formData.append("AuthorName", data.authorName);
|
||||
formData.append("AuthorNid", data.authorNid);
|
||||
formData.append("PetitionBodyDhiv", data.petitionBodyDhiv);
|
||||
formData.append("PetitionBodyEng", data.petitionBodyEng);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/Debug/upload-petition-form`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function submitSignature(
|
||||
petitionId: string,
|
||||
submission: SignatureSubmission,
|
||||
@@ -39,9 +102,10 @@ export async function submitSignature(
|
||||
}
|
||||
|
||||
// Dummy petition for development when API is not available
|
||||
export function getDummyPetition(petitionId: string): PetitionDetails {
|
||||
export function getDummyPetition(slug: string): PetitionDetails {
|
||||
return {
|
||||
id: petitionId || "dev-petition",
|
||||
id: "dev-petition-id",
|
||||
slug: slug || "demo-petition",
|
||||
nameEng: "Demo Petition: Improve Local Services",
|
||||
nameDhiv: "Demo Petition",
|
||||
startDate: new Date().toLocaleDateString(),
|
||||
|
||||
400
frontend-react/src/pages/CreatePetitionPage.tsx
Normal file
400
frontend-react/src/pages/CreatePetitionPage.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { submitPetition, type PetitionFormData } from "@/lib/api";
|
||||
import {
|
||||
FileText,
|
||||
Send,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
|
||||
function GuidelinesModal({ onAccept }: { onAccept: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="bg-amber-100 p-2 rounded-full">
|
||||
<AlertTriangle className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900">
|
||||
Before You Create a Petition
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-slate-700">
|
||||
<p>
|
||||
Please familiarize yourself with the laws and regulations for
|
||||
drafting a petition:
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="https://majlis.gov.mv/en/pes/petitions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
majlis.gov.mv/en/pes/petitions
|
||||
</a>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-700 font-medium">
|
||||
If you skip this step, there is a 100% chance we will reject
|
||||
hosting your petition.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 rounded-lg p-4 space-y-3">
|
||||
<p className="font-semibold text-slate-800">Important Rules (TLDR):</p>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-red-500 font-bold">1.</span>
|
||||
<span>
|
||||
You <strong>cannot</strong> mention people or businesses
|
||||
directly in your petition.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-red-500 font-bold">2.</span>
|
||||
<span>
|
||||
You <strong>cannot</strong> petition for something that only
|
||||
benefits yourself.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-red-500 font-bold">3.</span>
|
||||
<span>
|
||||
<strong>No anonymous petitions.</strong> Your Name and NID
|
||||
will appear publicly on the petition. The submitter's
|
||||
identity is always visible.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<button
|
||||
onClick={onAccept}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
|
||||
>
|
||||
I Understand, Continue
|
||||
</button>
|
||||
<a
|
||||
href="https://majlis.gov.mv/en/pes/petitions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-medium py-3 px-6 rounded-lg transition-colors text-center"
|
||||
>
|
||||
Read Full Guidelines First
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreatePetitionPage() {
|
||||
const navigate = useNavigate();
|
||||
const [showGuidelines, setShowGuidelines] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<{ slug: string } | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<PetitionFormData>({
|
||||
slug: "",
|
||||
nameDhiv: "",
|
||||
nameEng: "",
|
||||
startDate: formatDateForInput(new Date()),
|
||||
authorName: "",
|
||||
authorNid: "",
|
||||
petitionBodyDhiv: "",
|
||||
petitionBodyEng: "",
|
||||
});
|
||||
|
||||
function formatDateForInput(date: Date): string {
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const year = date.getFullYear();
|
||||
return `${day}-${month}-${year}`;
|
||||
}
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const generateSlug = () => {
|
||||
const slug = formData.nameEng
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.trim();
|
||||
setFormData((prev) => ({ ...prev, slug }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await submitPetition(formData);
|
||||
setSuccess({ slug: result.slug });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to submit petition");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50/50 p-4 md:p-8 font-sans">
|
||||
<div className="max-w-3xl mx-auto bg-white rounded-xl shadow-sm border border-slate-100 p-6 md:p-10">
|
||||
<div className="text-center space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-green-100 p-4 rounded-full">
|
||||
<CheckCircle className="w-12 h-12 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
Petition Created Successfully!
|
||||
</h1>
|
||||
<p className="text-slate-600">
|
||||
Your petition has been submitted and is now live.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mt-6">
|
||||
<button
|
||||
onClick={() => navigate(`/Petition/${success.slug}`)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
|
||||
>
|
||||
View Petition
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSuccess(null);
|
||||
setFormData({
|
||||
slug: "",
|
||||
nameDhiv: "",
|
||||
nameEng: "",
|
||||
startDate: formatDateForInput(new Date()),
|
||||
authorName: "",
|
||||
authorNid: "",
|
||||
petitionBodyDhiv: "",
|
||||
petitionBodyEng: "",
|
||||
});
|
||||
}}
|
||||
className="bg-slate-100 hover:bg-slate-200 text-slate-700 font-medium py-3 px-6 rounded-lg transition-colors"
|
||||
>
|
||||
Create Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50/50 p-4 md:p-8 font-sans">
|
||||
{showGuidelines && (
|
||||
<GuidelinesModal onAccept={() => setShowGuidelines(false)} />
|
||||
)}
|
||||
|
||||
<div className="max-w-3xl mx-auto bg-white rounded-xl shadow-sm border border-slate-100 p-6 md:p-10 animate-in fade-in duration-500 slide-in-from-bottom-4">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="bg-blue-100 p-3 rounded-full">
|
||||
<FileText className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
Create a New Petition
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Petition Names */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Petition Name (English) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="nameEng"
|
||||
value={formData.nameEng}
|
||||
onChange={handleChange}
|
||||
onBlur={() => !formData.slug && generateSlug()}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="Enter petition title in English"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Petition Name (Dhivehi) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="nameDhiv"
|
||||
value={formData.nameDhiv}
|
||||
onChange={handleChange}
|
||||
required
|
||||
dir="rtl"
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors font-dhivehi"
|
||||
placeholder="ދިވެހި ނަން ލިޔުއްވާ"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
URL Slug *
|
||||
</label>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
name="slug"
|
||||
value={formData.slug}
|
||||
onChange={handleChange}
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
className="flex-1 min-w-0 px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="my-petition-name"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateSlug}
|
||||
className="px-4 py-3 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg transition-colors text-sm whitespace-nowrap"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1 break-all">
|
||||
URL: /Petition/{formData.slug || "your-slug"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Start Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Start Date *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="startDate"
|
||||
value={formData.startDate}
|
||||
onChange={handleChange}
|
||||
required
|
||||
pattern="\d{2}-\d{2}-\d{4}"
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="dd-MM-yyyy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Author Info */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Author Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="authorName"
|
||||
value={formData.authorName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="Enter author name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Author National ID *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="authorNid"
|
||||
value={formData.authorNid}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="A123456"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Petition Bodies */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Petition Body (English) *
|
||||
</label>
|
||||
<textarea
|
||||
name="petitionBodyEng"
|
||||
value={formData.petitionBodyEng}
|
||||
onChange={handleChange}
|
||||
required
|
||||
rows={6}
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors resize-y"
|
||||
placeholder="Enter the full petition text in English..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Petition Body (Dhivehi) *
|
||||
</label>
|
||||
<textarea
|
||||
name="petitionBodyDhiv"
|
||||
value={formData.petitionBodyDhiv}
|
||||
onChange={handleChange}
|
||||
required
|
||||
rows={6}
|
||||
dir="rtl"
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors resize-y font-dhivehi"
|
||||
placeholder="ޕެޓިޝަންގެ ތަފްސީލް ދިވެހި ބަހުން ލިޔުއްވާ..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-3 px-6 rounded-lg transition-colors"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
Create Petition
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer className="text-center text-slate-500 text-sm mt-6 pb-4">
|
||||
Powered by Mv Devs Union
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend-react/src/pages/HomePage.tsx
Normal file
50
frontend-react/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { FileText, PenLine } from "lucide-react";
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50/50 p-4 md:p-8 font-sans">
|
||||
<div className="max-w-3xl mx-auto bg-white rounded-xl shadow-sm border border-slate-100 p-6 md:p-10 animate-in fade-in duration-500 slide-in-from-bottom-4">
|
||||
<div className="text-center space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-blue-100 p-4 rounded-full">
|
||||
<FileText className="w-12 h-12 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-slate-900">
|
||||
Petition.com.mv
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-slate-600 max-w-xl mx-auto">
|
||||
A platform for creating and signing petitions. Make your voice
|
||||
heard on issues that matter to you and your community.
|
||||
</p>
|
||||
|
||||
<div className="bg-slate-50 rounded-lg p-6 mt-8">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-2">
|
||||
Looking for a petition?
|
||||
</h2>
|
||||
<p className="text-slate-600">
|
||||
Use the direct link shared with you to view and sign a petition.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Link
|
||||
to="/CreatePetition"
|
||||
className="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
|
||||
>
|
||||
<PenLine className="w-5 h-5" />
|
||||
Create a Petition
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="text-center text-slate-500 text-sm mt-6 pb-4">
|
||||
Powered by Mv Devs Union
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
frontend-react/src/pages/PetitionPage.tsx
Normal file
123
frontend-react/src/pages/PetitionPage.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePetition } from "@/hooks/usePetition";
|
||||
import { useLanguage } from "@/hooks/useLanguage";
|
||||
import { submitSignature } from "@/lib/api";
|
||||
import { LanguageSwitcher } from "@/components/layout/LanguageSwitcher";
|
||||
import { LoadingState } from "@/components/layout/LoadingState";
|
||||
import { ErrorState } from "@/components/layout/ErrorState";
|
||||
import { PetitionHeader } from "@/components/petition/PetitionHeader";
|
||||
import { AuthorCard } from "@/components/petition/AuthorCard";
|
||||
import { PetitionBody } from "@/components/petition/PetitionBody";
|
||||
import { SignatureForm } from "@/components/signature/SignatureForm";
|
||||
import { TweetModal } from "@/components/TweetModal";
|
||||
import { PenLine } from "lucide-react";
|
||||
|
||||
export function PetitionPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { petition, loading, error, refetch } = usePetition(slug ?? null);
|
||||
const { language, setLanguage } = useLanguage();
|
||||
const [showTweetModal, setShowTweetModal] = useState(false);
|
||||
const signatureFormRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToSignForm = () => {
|
||||
signatureFormRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: {
|
||||
name: string;
|
||||
idCard: string;
|
||||
signature: string;
|
||||
turnstileToken: string;
|
||||
}) => {
|
||||
if (!petition?.id) throw new Error("No petition ID");
|
||||
|
||||
await submitSignature(petition.id, data);
|
||||
|
||||
// Show tweet modal after successful submission
|
||||
setShowTweetModal(true);
|
||||
|
||||
// Refresh petition data to update signature count
|
||||
setTimeout(() => {
|
||||
refetch();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// No slug in URL
|
||||
if (!slug) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-5">
|
||||
<div className="max-w-4xl mx-auto bg-card rounded-lg shadow-lg p-10">
|
||||
<ErrorState message="No petition found. Please use a valid petition URL." />
|
||||
</div>
|
||||
<footer className="text-center text-slate-500 text-sm mt-6 pb-4">
|
||||
Powered by Mv Devs Union
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50/50 p-4 md:p-8 font-sans">
|
||||
<div className="max-w-3xl mx-auto bg-white rounded-xl shadow-sm border border-slate-100 p-6 md:p-10 animate-in fade-in duration-500 slide-in-from-bottom-4">
|
||||
{loading ? (
|
||||
<div className="min-h-[400px] flex flex-col justify-center">
|
||||
<LoadingState language={language} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
{petition && (
|
||||
<div className="space-y-8">
|
||||
<LanguageSwitcher
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
/>
|
||||
|
||||
<PetitionHeader petition={petition} language={language} />
|
||||
|
||||
<button
|
||||
onClick={scrollToSignForm}
|
||||
className={`w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors ${language === "dv" ? "flex-row-reverse dhivehi" : ""}`}
|
||||
>
|
||||
<PenLine className="w-5 h-5" />
|
||||
{language === "dv" ? "މިހާރު ސޮއި ކުރައްވާ" : "Sign Now"}
|
||||
</button>
|
||||
|
||||
<AuthorCard
|
||||
author={petition.authorDetails}
|
||||
language={language}
|
||||
/>
|
||||
|
||||
<PetitionBody
|
||||
bodyEng={petition.petitionBodyEng}
|
||||
bodyDhiv={petition.petitionBodyDhiv}
|
||||
language={language}
|
||||
/>
|
||||
|
||||
<div ref={signatureFormRef}>
|
||||
<SignatureForm language={language} onSubmit={handleSubmit} />
|
||||
</div>
|
||||
|
||||
<TweetModal
|
||||
open={showTweetModal}
|
||||
onClose={() => setShowTweetModal(false)}
|
||||
petition={petition}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="text-center text-slate-500 text-sm mt-6 pb-4">
|
||||
Powered by Mv Devs Union
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export interface Author {
|
||||
|
||||
export interface PetitionDetails {
|
||||
id: string;
|
||||
slug: string;
|
||||
startDate: string;
|
||||
nameDhiv: string;
|
||||
nameEng: string;
|
||||
|
||||
Reference in New Issue
Block a user