create petitions

added support to create petitions from the website
This commit is contained in:
fISHIE
2026-01-28 15:22:03 +05:00
parent 80242d7621
commit 327dadff22
14 changed files with 715 additions and 1336 deletions

View File

@@ -5,7 +5,8 @@
"Bash(docker compose:*)",
"Bash(chmod:*)",
"Bash(tree:*)",
"Bash(./ci-helper:*)"
"Bash(./ci-helper:*)",
"Bash(npm run build:*)"
]
}
}

View File

@@ -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.

View File

@@ -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">&times;</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>

View File

@@ -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;
}
}

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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>
);
}

View File

@@ -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();

View File

@@ -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(),

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -5,6 +5,7 @@ export interface Author {
export interface PetitionDetails {
id: string;
slug: string;
startDate: string;
nameDhiv: string;
nameEng: string;