react frontend

This commit is contained in:
Evan
2026-01-17 01:48:34 +05:00
parent 39b96cadd5
commit 7b66396050
43 changed files with 6708 additions and 35 deletions

24
frontend-react/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend-react/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]);
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from "eslint-plugin-react-x";
import reactDom from "eslint-plugin-react-dom";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs["recommended-typescript"],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]);
```

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,23 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
]);

19
frontend-react/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
rel="stylesheet"
/>
<title>frontend-react</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4724
frontend-react/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
{
"name": "frontend-react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"format": "prettier --write .",
"preview": "vite preview"
},
"dependencies": {
"@marsidev/react-turnstile": "^1.4.1",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.3.1",
"lucide-react": "^0.562.0",
"marked": "^17.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-signature-canvas": "^1.1.0-alpha.2",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/dompurify": "^3.0.5",
"@types/node": "^24.10.9",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/react-signature-canvas": "^1.0.7",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.8.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

105
frontend-react/src/App.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { useState } 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";
function getPetitionIdFromUrl(): string | null {
const urlParams = new URLSearchParams(window.location.search);
const id = urlParams.get("id");
return id;
}
function App() {
const petitionId = getPetitionIdFromUrl();
const { petition, loading, error, refetch } = usePetition(petitionId);
const { language, setLanguage } = useLanguage();
const [showTweetModal, setShowTweetModal] = useState(false);
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>
</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} />
<AuthorCard
author={petition.authorDetails}
language={language}
/>
<PetitionBody
bodyEng={petition.petitionBodyEng}
bodyDhiv={petition.petitionBodyDhiv}
language={language}
/>
<SignatureForm language={language} onSubmit={handleSubmit} />
<TweetModal
open={showTweetModal}
onClose={() => setShowTweetModal(false)}
petition={petition}
language={language}
/>
</div>
)}
</>
)}
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,91 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import type { PetitionDetails, Language } from "@/types/petition";
// X/Twitter icon from Simple Icons
function XIcon({ className }: { className?: string }) {
return (
<svg
role="img"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z" />
</svg>
);
}
interface TweetModalProps {
open: boolean;
onClose: () => void;
petition: PetitionDetails;
language: Language;
}
export function TweetModal({
open,
onClose,
petition,
language,
}: TweetModalProps) {
const generateTweetText = () => {
const petitionName =
language === "dv" ? petition.nameDhiv : petition.nameEng;
const petitionUrl = `${window.location.origin}${window.location.pathname}?id=${petition.id}`;
return `I just signed "${petitionName}"! \n\nAdd your signature: ${petitionUrl}`;
};
const openTwitterIntent = () => {
const tweetText = generateTweetText();
const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}`;
window.open(twitterUrl, "_blank");
onClose();
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className={language === "dv" ? "dhivehi" : ""}>
{language === "en"
? "Share this petition!"
: "މި ޕެޓިޝަން ހިއްސާކުރައްވާ!"}
</DialogTitle>
<DialogDescription className={language === "dv" ? "dhivehi" : ""}>
{language === "en"
? "Help spread the word by posting about this petition."
: "މި ޕެޓިޝަންގައި ވީހާވެސް ގިނަ ބަޔަކު ބައިވެރިކުރުމަށްޓަކައި އެކްސްގައި ޕޯސްޓް ކޮށްދެއްވާ."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 mt-4">
<Button
onClick={openTwitterIntent}
className={`w-full bg-black hover:bg-neutral-800 ${language === "dv" ? "dhivehi" : ""}`}
>
{language === "en" ? "Post" : "ޕޯސްޓް ކުރައްވާ"}
<XIcon className="h-4 w-4 ml-2" />
</Button>
<Button
variant="secondary"
onClick={onClose}
className={`w-full ${language === "dv" ? "dhivehi" : ""}`}
>
<X className="h-4 w-4 mr-2" />
{language === "en" ? "Maybe Later" : "ފަހުން"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,15 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
interface ErrorStateProps {
message: string;
}
export function ErrorState({ message }: ErrorStateProps) {
return (
<Alert variant="destructive" className="mb-5">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{message}</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,43 @@
import { Button } from "@/components/ui/button";
import type { Language } from "@/types/petition";
interface LanguageSwitcherProps {
language: Language;
onLanguageChange: (lang: Language) => void;
}
export function LanguageSwitcher({
language,
onLanguageChange,
}: LanguageSwitcherProps) {
return (
<div className="flex gap-1 justify-end mb-6">
<div className="bg-slate-100 p-1 rounded-full inline-flex border border-slate-200">
<Button
variant={language === "en" ? "default" : "ghost"}
size="sm"
onClick={() => onLanguageChange("en")}
className={`rounded-full px-4 text-sm font-medium transition-all ${
language === "en"
? "shadow-sm bg-white text-slate-900 border border-slate-200"
: "text-slate-500 hover:text-slate-900 hover:bg-slate-200/50"
}`}
>
English
</Button>
<Button
variant={language === "dv" ? "default" : "ghost"}
size="sm"
onClick={() => onLanguageChange("dv")}
className={`rounded-full px-4 text-sm font-medium transition-all dhivehi ${
language === "dv"
? "shadow-sm bg-white text-slate-900 border border-slate-200"
: "text-slate-500 hover:text-slate-900 hover:bg-slate-200/50"
}`}
>
ދިވެހި
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Loader2 } from "lucide-react";
import type { Language } from "@/types/petition";
interface LoadingStateProps {
language?: Language;
}
export function LoadingState({ language = "en" }: LoadingStateProps) {
return (
<div className="flex items-center justify-center p-10 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
<span className={language === "dv" ? "dhivehi" : ""}>
{language === "en" ? "Loading petition..." : "ޕެޓިޝަން ލޯޑުވަނީ..."}
</span>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Author, Language } from "@/types/petition";
interface AuthorCardProps {
author: Author;
language: Language;
}
export function AuthorCard({ author, language }: AuthorCardProps) {
const isRtl = language === "dv";
return (
<Card className="mb-8 border border-slate-100 shadow-sm bg-slate-50/50">
<CardHeader className="pb-2">
<CardTitle
className={`text-sm font-medium text-slate-500 uppercase tracking-wider ${isRtl ? "dhivehi" : ""}`}
>
{language === "en" ? "Author Details" : "ލިޔުންތެރިގެ މައުލޫމާތު"}
</CardTitle>
</CardHeader>
<CardContent>
<p
className={`text-lg font-medium text-slate-900 ${isRtl ? "dhivehi" : ""}`}
>
{author.name}
</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,44 @@
import { useMemo } from "react";
import { marked } from "marked";
import DOMPurify from "dompurify";
import type { Language } from "@/types/petition";
interface PetitionBodyProps {
bodyEng: string;
bodyDhiv: string;
language: Language;
}
export function PetitionBody({
bodyEng,
bodyDhiv,
language,
}: PetitionBodyProps) {
const content = language === "dv" ? bodyDhiv : bodyEng;
const isRtl = language === "dv";
const htmlContent = useMemo(() => {
const raw = marked.parse(content) as string;
return DOMPurify.sanitize(raw);
}, [content]);
return (
<div className="mt-8">
<div
className={`leading-loose prose prose-slate max-w-none text-slate-800 md:prose-lg
prose-p:mb-6 prose-p:mt-0
prose-headings:text-slate-900 prose-headings:font-bold prose-headings:mb-6 prose-headings:mt-12
[&_hr]:my-8! [&_hr]:border-slate-300!
[&_ul]:list-disc! [&_ul]:my-6!
[&_ol]:list-decimal! [&_ol]:my-6!
[&_li]:my-2!
[&_li::before]:content-none!
${isRtl ? "dhivehi dir-rtl text-right [&_ul]:pr-12! [&_ol]:pr-12!" : "text-left [&_ul]:pl-12! [&_ol]:pl-12!"}`}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { Badge } from "@/components/ui/badge";
import type { PetitionDetails, Language } from "@/types/petition";
interface PetitionHeaderProps {
petition: PetitionDetails;
language: Language;
}
export function PetitionHeader({ petition, language }: PetitionHeaderProps) {
const isRtl = language === "dv";
return (
<div className="border-b border-slate-100 pb-6 mb-8">
{language === "en" ? (
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-3 tracking-tight leading-tight">
{petition.nameEng}
</h1>
) : (
<h1 className="text-2xl md:text-3xl font-bold text-slate-900 mb-3 dhivehi leading-relaxed line-clamp-3">
{petition.nameDhiv}
</h1>
)}
<div
className={`flex flex-wrap gap-3 mt-4 ${isRtl ? "flex-row-reverse" : ""}`}
>
<Badge
variant="secondary"
className={`text-sm px-3 py-1 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 border-0 ${isRtl ? "dhivehi" : ""}`}
>
{language === "en" ? "Start Date:" : "ފެށި ތާރީހު:"}{" "}
<span className="font-semibold ml-1">{petition.startDate}</span>
</Badge>
<Badge
variant="secondary"
className={`text-sm px-3 py-1 rounded-full bg-blue-50 text-blue-700 hover:bg-blue-100 border-0 ${isRtl ? "dhivehi" : ""}`}
>
{language === "en" ? "Signatures:" : "ސޮއި:"}{" "}
<span className="font-bold ml-1">{petition.signatureCount}</span>
</Badge>
</div>
</div>
);
}

View File

@@ -0,0 +1,286 @@
import { useState, useRef, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { SignaturePad, type SignaturePadRef } from "./SignaturePad";
import { Turnstile } from "@marsidev/react-turnstile";
import { Eraser, Send, CheckCircle, AlertCircle } from "lucide-react";
import type { Language } from "@/types/petition";
interface SignatureFormProps {
language: Language;
onSubmit: (data: {
name: string;
idCard: string;
signature: string;
turnstileToken: string;
}) => Promise<void>;
}
export function SignatureForm({ language, onSubmit }: SignatureFormProps) {
const [name, setName] = useState("");
const [idCardDigits, setIdCardDigits] = useState("");
const [consent, setConsent] = useState(false);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const [message, setMessage] = useState<{
text: string;
type: "success" | "error";
} | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const signaturePadRef = useRef<SignaturePadRef>(null);
const isRtl = language === "dv";
const handleClear = () => {
signaturePadRef.current?.clear();
};
const showMessage = (text: string, type: "success" | "error") => {
setMessage({ text, type });
setTimeout(() => setMessage(null), 5000);
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
// Validate signature
if (signaturePadRef.current?.isEmpty()) {
showMessage(
language === "en" ? "Please provide your signature." : "ސޮއި ލިޔުއްވާ.",
"error",
);
return;
}
// Validate consent
if (!consent) {
showMessage(
language === "en"
? "Please confirm that your submission will be sent to the Parliament Petition Committee and that your information is true."
: "މައުލޫމާތު ކަށަވަރުކޮށް، ޕެޓިޝަން ކޮމިޓީއަށް ފޮނުވުމަށް އެއްބަސްވެލައްވާ.",
"error",
);
return;
}
// Validate ID card (6 digits)
const idCardPattern = /^\d{6}$/;
if (!idCardPattern.test(idCardDigits)) {
showMessage(
language === "en"
? "Please enter your ID number as 6 digits (numbers only)."
: "ކާޑު ނަންބަރު 6 އަކުރު ޖައްސަވާ.",
"error",
);
return;
}
// Validate turnstile
if (!turnstileToken && !import.meta.env.DEV) {
showMessage(
language === "en"
? "Please complete the captcha challenge."
: "ކެޕްޗާ ފުރިހަމަ ކުރައްވާ.",
"error",
);
return;
}
const signature = signaturePadRef.current?.toSVG();
if (!signature) {
showMessage(
language === "en" ? "Please provide your signature." : "ސޮއި ލިޔުއްވާ.",
"error",
);
return;
}
setIsSubmitting(true);
try {
await onSubmit({
name,
idCard: "A" + idCardDigits,
signature,
turnstileToken: turnstileToken || "DEV_BYPASS_TOKEN",
});
showMessage(
language === "en"
? "Signature submitted successfully!"
: "ސޮއި ކޯމިއުކުރެވިއްޖެ!",
"success",
);
// Reset form
setName("");
setIdCardDigits("");
setConsent(false);
signaturePadRef.current?.clear();
} catch (error) {
showMessage(
language === "en"
? `Failed to submit signature: ${error instanceof Error ? error.message : "Unknown error"}`
: "ސޮއި ހުށަހެޅުމުން ކުށެއް ދިމާވެއްޖެ.",
"error",
);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="mt-12 pt-8 border-t border-slate-100">
<h3
className={`text-xl font-semibold text-slate-900 mb-8 ${isRtl ? "dhivehi" : ""}`}
>
{language === "en"
? "Sign this Petition"
: "މި މައްސަލައިގައި ސޮއި ކުރައްވާ"}
</h3>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name Field */}
<div className="space-y-3">
<Label
htmlFor="name"
className={`text-slate-700 ${isRtl ? "dhivehi" : ""}`}
>
{language === "en" ? "Full Name (As on ID card)" : "ފުރިހަމަ ނަން"}
</Label>
<Input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
minLength={3}
maxLength={30}
required
className={isRtl ? "text-right" : ""}
placeholder={language === "en" ? "Enter your full name" : ""}
/>
</div>
{/* ID Card Field */}
<div className="space-y-3">
<Label
htmlFor="idCard"
className={`text-slate-700 ${isRtl ? "dhivehi" : ""}`}
>
{language === "en" ? "ID Card Number" : "ކާޑު ނަންބަރު"}
</Label>
<div
className={`flex items-center w-full sm:w-64 border border-input rounded-md overflow-hidden bg-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 transition-all ${isRtl ? "flex-row-reverse" : ""}`}
>
<span className="px-4 py-2 font-semibold text-slate-500 bg-slate-50 border-r border-slate-100">
A
</span>
<Input
id="idCard"
type="tel"
inputMode="numeric"
pattern="\d{6}"
value={idCardDigits}
onChange={(e) =>
setIdCardDigits(e.target.value.replace(/\D/g, "").slice(0, 6))
}
maxLength={6}
required
placeholder="XXXXXX"
className="border-0 focus-visible:ring-0 text-left shadow-none"
dir="ltr"
/>
</div>
</div>
{/* Signature Pad */}
<div className="space-y-3">
<Label className={`text-slate-700 ${isRtl ? "dhivehi" : ""}`}>
{language === "en" ? "Signature" : "ސޮއި"}
</Label>
<div className="border border-input rounded-lg overflow-hidden shadow-sm hover:border-ring/50 transition-colors">
<SignaturePad ref={signaturePadRef} />
</div>
<p className="text-xs text-slate-500">
{language === "en"
? "Sign in the box above"
: "މަތީގައިވާ ގޮޅީގައި ސޮއި ކުރައްވާ"}
</p>
</div>
{/* Consent Checkbox */}
<div className="flex items-start gap-3 p-4 bg-slate-50 rounded-lg border border-slate-100">
<Checkbox
id="consent"
checked={consent}
onCheckedChange={(checked) => setConsent(checked === true)}
className="mt-1"
/>
<Label
htmlFor="consent"
className={`text-sm leading-relaxed cursor-pointer text-slate-600 ${isRtl ? "dhivehi" : ""}`}
>
{language === "en"
? "I acknowledge my signature and information will be sent to the Parliament Petition Committee, and that the information I provide is true."
: "މި ޕެޓިޝަނުގައިވާ މައުލޫމާތާއި އަޅުގަނޑުގެ ސޮއި ރައްޔިތުންގެ މަޖިލީހުގެ ޕެޓިޝަން ކޮމިޓީއަށް ހުށަހެޅޭނެކަން އަޅުގަނޑު އިޤްރާރުވަމެވެ. އަދި އަޅުގަނޑު ދީފައިވާ މައުލޫމާތަކީ ތެދު މައުލޫމާތެވެ."}
</Label>
</div>
{/* Message Display */}
{message && (
<Alert
variant={message.type === "error" ? "destructive" : "default"}
className="animate-in fade-in slide-in-from-top-2"
>
{message.type === "error" ? (
<AlertCircle className="h-4 w-4" />
) : (
<CheckCircle className="h-4 w-4" />
)}
<AlertDescription>{message.text}</AlertDescription>
</Alert>
)}
{/* Turnstile */}
<div className="flex justify-center sm:justify-start">
<Turnstile
siteKey="0x4AAAAAACHH4QC3wIhkCuhd"
onSuccess={setTurnstileToken}
onError={() => setTurnstileToken(null)}
onExpire={() => setTurnstileToken(null)}
/>
</div>
{/* Form Buttons */}
<div className={`flex gap-4 pt-4 ${isRtl ? "flex-row-reverse" : ""}`}>
<Button
type="button"
variant="ghost"
onClick={handleClear}
className={`text-slate-500 hover:text-slate-900 ${isRtl ? "dhivehi" : ""}`}
>
<Eraser className="h-4 w-4 mr-2" />
{language === "en" ? "Clear Signature" : "ފޮހެލާ"}
</Button>
<Button
type="submit"
disabled={isSubmitting}
size="lg"
className={`min-w-[140px] shadow-md hover:shadow-lg transition-all ${isRtl ? "dhivehi" : ""}`}
>
{isSubmitting ? (
<span className="animate-pulse">Submitting...</span>
) : (
<>
<Send className="h-4 w-4 mr-2" />
{language === "en" ? "Submit Petition" : "ހުށަހެޅުއް"}
</>
)}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useRef, useImperativeHandle, forwardRef } from "react";
import SignatureCanvas from "react-signature-canvas";
export interface SignaturePadRef {
clear: () => void;
isEmpty: () => boolean;
toSVG: () => string | null;
}
interface SignaturePadProps {
onBegin?: () => void;
onEnd?: () => void;
}
export const SignaturePad = forwardRef<SignaturePadRef, SignaturePadProps>(
({ onBegin, onEnd }, ref) => {
const sigCanvasRef = useRef<SignatureCanvas>(null);
useImperativeHandle(ref, () => ({
clear: () => {
sigCanvasRef.current?.clear();
},
isEmpty: () => {
return sigCanvasRef.current?.isEmpty() ?? true;
},
toSVG: () => {
if (!sigCanvasRef.current || sigCanvasRef.current.isEmpty()) {
return null;
}
// Get the raw data points from signature canvas
const data = sigCanvasRef.current.toData();
if (!data || data.length === 0) {
return null;
}
// Convert to SVG path
let pathData = "";
for (const stroke of data) {
const points = stroke as unknown as { x: number; y: number }[];
if (points && points.length > 0) {
pathData += `M ${points[0].x} ${points[0].y} `;
for (let i = 1; i < points.length; i++) {
pathData += `L ${points[i].x} ${points[i].y} `;
}
}
}
const canvas = sigCanvasRef.current.getCanvas();
const width = canvas.width;
const height = canvas.height;
return `<svg width="${width}" height="${height}" 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 (
<div className="signature-pad-container">
<SignatureCanvas
ref={sigCanvasRef}
penColor="black"
canvasProps={{
className: "w-full",
style: { aspectRatio: "3", height: "auto" },
}}
onBegin={onBegin}
onEnd={onEnd}
/>
</div>
);
},
);
SignaturePad.displayName = "SignaturePad";

View File

@@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,62 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -0,0 +1,141 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,60 @@
import { useState, useEffect, useCallback } from "react";
import type { Language } from "@/types/petition";
function getLangFromUrl(): Language {
const urlParams = new URLSearchParams(window.location.search);
const lang = urlParams.get("lang");
return lang === "dv" ? "dv" : "en";
}
function updateUrlWithLang(lang: Language): void {
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);
}
interface UseLanguageResult {
language: Language;
setLanguage: (lang: Language) => void;
t: <T>(en: T, dv: T) => T;
}
export function useLanguage(): UseLanguageResult {
const [language, setLanguageState] = useState<Language>(() =>
getLangFromUrl(),
);
const setLanguage = useCallback((lang: Language) => {
setLanguageState(lang);
updateUrlWithLang(lang);
// Set document direction for RTL languages (Dhivehi uses RTL script)
document.documentElement.dir = lang === "dv" ? "rtl" : "ltr";
}, []);
// Set initial direction on mount
useEffect(() => {
document.documentElement.dir = language === "dv" ? "rtl" : "ltr";
}, [language]);
// Helper function to get text based on current language
const t = useCallback(
<T>(en: T, dv: T): T => {
return language === "dv" ? dv : en;
},
[language],
);
return {
language,
setLanguage,
t,
};
}

View File

@@ -0,0 +1,55 @@
import { useState, useEffect, useCallback } from "react";
import type { PetitionDetails } from "@/types/petition";
import { fetchPetition, getDummyPetition } from "@/lib/api";
interface UsePetitionResult {
petition: PetitionDetails | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
export function usePetition(petitionId: 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) {
setLoading(false);
setError("No petition ID provided");
return;
}
setLoading(true);
setError(null);
try {
const data = await fetchPetition(petitionId);
setPetition(data);
} catch (err) {
console.warn(
"Failed to fetch petition, falling back to dummy data.",
err,
);
// Use dummy data for development
setError(
"Failed to load petition from server — showing dummy data for development.",
);
setPetition(getDummyPetition(petitionId));
} finally {
setLoading(false);
}
}, [petitionId]);
useEffect(() => {
loadPetition();
}, [loadPetition]);
return {
petition,
loading,
error,
refetch: loadPetition,
};
}

View File

@@ -0,0 +1,199 @@
@import "tailwindcss";
@import "tw-animate-css";
@theme {
--font-sans:
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
@custom-variant dark (&:is(.dark *));
/* Custom Dhivehi Fonts */
@font-face {
font-family: "Utheem";
src: url("/fonts/utheem.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "Faruma";
src: url("/fonts/Faruma.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "Waheed";
src: url("/fonts/MVWaheed.otf") format("opentype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "A_Waheed";
src: url("/fonts/A_Waheed.otf") format("opentype");
font-weight: normal;
font-style: normal;
}
/* Theme variables for shadcn */
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: 210 40% 98%;
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: 210 40% 98%;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
/* Base styles */
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
}
/* Dhivehi text styles - using Faruma as primary font */
.dhivehi {
font-family: "Faruma", "Waheed", sans-serif;
direction: rtl;
text-align: right;
}
/* RTL document support */
html[dir="rtl"] {
direction: rtl;
}
/* Signature pad styling */
.signature-pad-container {
@apply border-2 border-border rounded-lg bg-white cursor-crosshair;
touch-action: none;
}
.signature-pad-container canvas {
display: block;
width: 100%;
height: auto;
aspect-ratio: 3;
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,56 @@
import type { PetitionDetails, SignatureSubmission } from "@/types/petition";
// API base URL - empty for same-origin requests through Vite proxy
const API_BASE_URL = "";
export async function fetchPetition(
petitionId: string,
): Promise<PetitionDetails> {
const response = await fetch(
`${API_BASE_URL}/api/Sign/petition/${petitionId}`,
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
export async function submitSignature(
petitionId: string,
submission: SignatureSubmission,
): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/api/Sign/petition/${petitionId}`,
{
method: "POST",
headers: {
accept: "*/*",
"Content-Type": "application/json",
},
body: JSON.stringify(submission),
},
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
// Dummy petition for development when API is not available
export function getDummyPetition(petitionId: string): PetitionDetails {
return {
id: petitionId || "dev-petition",
nameEng: "Demo Petition: Improve Local Services",
nameDhiv: "Demo Petition",
startDate: new Date().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)",
};
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,24 @@
export interface Author {
name: string;
nid?: string;
}
export interface PetitionDetails {
id: string;
startDate: string;
nameDhiv: string;
nameEng: string;
authorDetails: Author;
petitionBodyDhiv: string;
petitionBodyEng: string;
signatureCount: number;
}
export interface SignatureSubmission {
name: string;
idCard: string;
signature: string; // SVG string
turnstileToken: string;
}
export type Language = "en" | "dv";

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path alias for shadcn */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api": {
target: "http://localhost:9755",
changeOrigin: true,
},
},
},
});

View File

@@ -7,7 +7,6 @@ author:
nid: "AAAAA12345"
---
## Petition Body (Dhivehi)
## Petition Body (Dhivehi)
ދިވެހިރާއްޖޭގެ ރައްޔިތުން ކަމުގައިވާ އަޅުގަނޑުމެން، ދިވެހި ރައްޔިތުންގެ އަސާސީ ޙައްޤުތަކާއި، ޕްރައިވެސީއާއި، ޑިޖިޓަލް ސެކިއުރިޓީ ރައްކާތެރިކުރުމަށް ޒަމާނީ، ފުރިހަމަ ޤާނޫނީ އޮނިގަނޑެއް ނެތުމުގެ މައްސަލައަށް ޙައްލެއް ހޯދުމަށް މި ޕެޓިޝަން އިޙްތިރާމާއެކު ހުށަހަޅަމެވެ.
@@ -18,35 +17,32 @@ author:
---
ހ. ފުރިހަމަ ޑޭޓާ ޕްރޮޓެކްޝަން ޤާނޫނެއް ފާސްކުރުން (ބައިނަލްއަޤްވާމީ މިންގަނޑުތަކާ އެއްގޮތަށް)
**ހ. ފުރިހަމަ ޑޭޓާ ޕްރޮޓެކްޝަން ޤާނޫނެއް ފާސްކުރުން (ބައިނަލްއަޤްވާމީ މިންގަނޑުތަކާ އެއްގޮތަށް)**
ޒާތީ ޑޭޓާ ޤާނޫނީ ގޮތުން ޕްރޮސެސްކުރުމަށް މަތީ މިންގަނޑުތައް ކަނޑައަޅާ ޚާއްޞަ ޑޭޓާ ޕްރޮޓެކްޝަން ޤާނޫނެއް އެކުލަވާލައި، މަޝްވަރާކޮށް، ފާސްކުރުމަށް ރައްޔިތުންގެ މަޖިލީހަށް އިޙްތިރާމާއެކު އެދެމެވެ. ދިވެހިރާއްޖޭގެ އޮނިގަނޑު ހަރުދަނާ އަދި މުސްތަޤްބަލަށް ތައްޔާރުވެފައިވާ އެއްޗެއްކަން ކަށަވަރުކުރުމަށް ޔޫރަޕިއަން ޔޫނިއަންގެ ޖެނެރަލް ޑޭޓާ ޕްރޮޓެކްޝަން ރެގިއުލޭޝަން (ޖީޑީޕީއާރް) ފަދަ ބައިނަލްއަޤްވާމީ ގޮތުން ޤަބޫލުކުރެވޭ ރަނގަޅު އުސޫލުތަކުގެ މައްޗަށް ބިނާކުރަންވާނެއެވެ.
1. މި ޤާނޫނުގައި އަންނަނިވި އަސާސީ އުސޫލުތައް ހިމެނެންވާނެއެވެ. އެހެންނަމަވެސް މިއަށް ލިމިޓެއް ނުވެއެވެ:
1. **މި ޤާނޫނުގައި އަންނަނިވި އަސާސީ އުސޫލުތައް ހިމެނެންވާނެއެވެ. އެހެންނަމަވެސް މިއަށް ލިމިޓެއް ނުވެއެވެ:**
- **ޑޭޓާ ސަބްޖެކްޓްގެ އަސާސީ ޙައްޤުތައް:** އަންނަނިވި ޙައްޤުތައް ފަރުދުންނަށް ކަށަވަރުކޮށްދިނުން:
- އެމީހުންގެ ޒާތީ ޑޭޓާއަށް އެކްސެސްވުން
- ނުބައި ޑޭޓާ ރަނގަޅުކުރުން
- އެމީހުންގެ ޑޭޓާ ފޮހެލުން (ހަނދާން ނައްތާލެވުމުގެ ޙައްޤު)
- އެމީހުންގެ ޑޭޓާ ޕޯޓެބިލިޓީ
- ޑޭޓާ ސަބްޖެކްޓްގެ އަސާސީ ޙައްޤުތައް: އަންނަނިވި ޙައްޤުތައް ފަރުދުންނަށް ކަށަވަރުކޮށްދިނުން:
- އެމީހުންގެ ޒާތީ ޑޭޓާއަށް އެކްސެސްވުން
- ނުބައި ޑޭޓާ ރަނގަޅުކުރުން
- އެމީހުންގެ ޑޭޓާ ފޮހެލުން (ހަނދާން ނައްތާލެވުމުގެ ޙައްޤު)
- އެމީހުންގެ ޑޭޓާ ޕޯޓެބިލިޓީ
2. **ޕްރޮސެސިންގެ ޤާނޫނީ ބިންގާ:** ޒާތީ ޑޭޓާ ޕްރޮސެސްކުރުން ސާފު، ސީދާ، އަދި މަޢުލޫމާތު ލިބިގެން ދެވޭ ރުހުމެއް ނުވަތަ އެހެނިހެން ކަނޑައެޅިފައިވާ ޤާނޫނީ ބިންގަލެއްގެ މައްޗަށް ބިނާވާން ލާޒިމުކުރުން. ކުރިން ޓިކް ޖެހިފައިވާ ބޮކްސްތައް ނުވަތަ ރުހުން ލިބިއްޖެކަމަށް ބެލުން މަނާކުރަންވާނެއެވެ.
2. ޕްރޮސެސިންގެ ޤާނޫނީ ބިންގާ: ޒާތީ ޑޭޓާ ޕްރޮސެސްކުރުން ސާފު، ސީދާ، އަދި މަޢުލޫމާތު ލިބިގެން ދެވޭ ރުހުމެއް ނުވަތަ އެހެނިހެން ކަނޑައެޅިފައިވާ ޤާނޫނީ ބިންގަލެއްގެ މައްޗަށް ބިނާވާން ލާޒިމުކުރުން. ކުރިން ޓިކް ޖެހިފައިވާ ބޮކްސްތައް ނުވަތަ ރުހުން ލިބިއްޖެކަމަށް ބެލުން މަނާކުރަންވާނެއެވެ.
3. **އިލެކްޓްރޯނިކް މާކެޓިންގ ރުހުމާ ބެހޭ ޚާއްޞަ އެންގުން (އެދިގެން ނުވާ އެސްއެމްއެސް):**
- ޕްރޮމޯޝަނަލް އެސްއެމްއެސް ނުވަތަ އިލެކްޓްރޯނިކް މެސެޖިންގަށް ޒާތީ ޑޭޓާ (ފޯނު ނަންބަރު ފަދަ) ބޭނުންކުރުން މާކެޓިންގެ ބާވަތެއްގެ ގޮތުގައި މި ޤާނޫނުން ސީދާ ބަޔާންކޮށް، ސަބްސްކްރައިބަރުގެ ކުރީން ލިބޭ، ޚާއްޞަ، އަދި މަޢުލޫމާތު ލިބިގެން ދެވޭ އޮޕްޓް-އިން ރުހުން ބޭނުންވާކަން ކަނޑައަޅަންވާނެއެވެ.
3. އިލެކްޓްރޯނިކް މާކެޓިންގ ރުހުމާ ބެހޭ ޚާއްޞަ އެންގުން (އެދިގެން ނުވާ އެސްއެމްއެސް):
- މި ރުހުން ޢާންމު ޝަރުތުތަކާއި ކޮންޑިޝަންސްއާ ވަކިން ހޯދަންވާނެއެވެ. ހުރިހާ ޕްރޮމޯޝަނަލް މުވާސަލާތަކަށް ޑީފޯލްޓް ޕޮޒިޝަނަކީ އޮޕްޓް-އިން ކަމުގައި ކަށަވަރުކުރަންވާނެއެވެ (ފޮނުވާ ފަރާތުން ސީދާ ރުހުން ހޯދަންޖެހޭ). އޮޕްޓް-އައުޓް އެއް ނޫނެވެ.
- ޕްރޮމޯޝަނަލް އެސްއެމްއެސް ނުވަތަ އިލެކްޓްރޯނިކް މެސެޖިންގަށް ޒާތީ ޑޭޓާ (ފޯނު ނަންބަރު ފަދަ) ބޭނުންކުރުން މާކެޓިންގެ ބާވަތެއްގެ ގޮތުގައި މި ޤާނޫނުން ސީދާ ބަޔާންކޮށް، ސަބްސްކްރައިބަރުގެ ކުރީން ލިބޭ، ޚާއްޞަ، އަދި މަޢުލޫމާތު ލިބިގެން ދެވޭ އޮޕްޓް-އިން ރުހުން ބޭނުންވާކަން ކަނޑައަޅަންވާނެއެވެ.
- މި ޚާއްޞަ ރުހުމާ ނުލައި އެއްވެސް ސަބްސްކްރައިބަރަކަށް ވިޔަފާރި، މާކެޓިންގ، ނުވަތަ ޕްރޮމޯޝަނަލް އެސްއެމްއެސް އާއި މެސެޖު ފޮނުވުން މި ޤާނޫނުން މަނާކުރަންވާނެއެވެ.
- މި ރުހުން ޢާންމު ޝަރުތުތަކާއި ކޮންޑިޝަންސްއާ ވަކިން ހޯދަންވާނެއެވެ. ހުރިހާ ޕްރޮމޯޝަނަލް މުވާސަލާތަކަށް ޑީފޯލްޓް ޕޮޒިޝަނަކީ އޮޕްޓް-އިން ކަމުގައި ކަށަވަރުކުރަންވާނެއެވެ (ފޮނުވާ ފަރާތުން ސީދާ ރުހުން ހޯދަންޖެހޭ). އޮޕްޓް-އައުޓް އެއް ނޫނެވެ.
4. **މިނިވަން އޮތޯރިޓީއެއް އުފެއްދުން:** އެކަށީގެންވާ ވަސީލަތްތައް ލިބިފައިވާ މިނިވަން ޑޭޓާ ޕްރޮޓެކްޝަން އޮތޯރިޓީ (ޑީޕީއޭ) އެއް އުފެއްދުން. މި އޮތޯރިޓީއަށް އަންނަނިވި ބާރުތައް ދިނުން:
- ޤާނޫނު ތަންފީޒުކުރުން
- ޝަކުވާތައް ތަޙުޤީޤުކުރުން
- ޤާނޫނާ ޚިލާފުވާ ފަރާތްތަކަށް ބޮޑެތި އަދި ހަރުދަނާ އަދަބު ދިނުން
- މި ޚާއްޞަ ރުހުމާ ނުލައި އެއްވެސް ސަބްސްކްރައިބަރަކަށް ވިޔަފާރި، މާކެޓިންގ، ނުވަތަ ޕްރޮމޯޝަނަލް އެސްއެމްއެސް އާއި މެސެޖު ފޮނުވުން މި ޤާނޫނުން މަނާކުރަންވާނެއެވެ.
4. މިނިވަން އޮތޯރިޓީއެއް އުފެއްދުން: އެކަށީގެންވާ ވަސީލަތްތައް ލިބިފައިވާ މިނިވަން ޑޭޓާ ޕްރޮޓެކްޝަން އޮތޯރިޓީ (ޑީޕީއޭ) އެއް އުފެއްދުން. މި އޮތޯރިޓީއަށް އަންނަނިވި ބާރުތައް ދިނުން:
- ޤާނޫނު ތަންފީޒުކުރުން
- ޝަކުވާތައް ތަޙުޤީޤުކުރުން
- ޤާނޫނާ ޚިލާފުވާ ފަރާތްތަކަށް ބޮޑެތި އަދި ހަރުދަނާ އަދަބު ދިނުން
5. ޑޭޓާ ބްރީޗް އެންގުމުގެ އިލްތިޒާމު: ބޮޑު ޑޭޓާ ބްރީޗެއް ހިނގައިފިނަމަ ޑީޕީއޭއަށާއި އަސަރުކުރި ފަރާތްތަކަށް އަވަހަށް އެންގުން ޑޭޓާ ކޮންޓްރޯލަރުންނަށް ލާޒިމުކުރުން.
5. **ޑޭޓާ ބްރީޗް އެންގުމުގެ އިލްތިޒާމު:** ބޮޑު ޑޭޓާ ބްރީޗެއް ހިނގައިފިނަމަ ޑީޕީއޭއަށާއި އަސަރުކުރި ފަރާތްތަކަށް އަވަހަށް އެންގުން ޑޭޓާ ކޮންޓްރޯލަރުންނަށް ލާޒިމުކުރުން.
---
@@ -58,7 +54,6 @@ author:
ޤައުމީ މުހިންމުކަމެއް ކަމުގައިވާ މި މައްސަލައަށް މަޖިލީހުން އެދެވޭ ގޮތެއްގައި ބައްލަވާނެ ކަމަށް އުންމީދުކުރަމެވެ.
## Petition Body (English)
We, the citizens of the Republic of Maldives, respectfully submit this petition to address the critical lack of a modern, comprehensive framework to protect the fundamental rights, privacy, and digital security of the Maldivian people.
@@ -67,23 +62,25 @@ In the rapidly evolving digital age, personal data including names, contact deta
The widespread practice of sending unsolicited promotional SMS and messages is a clear example of this data processing abuse, constituting a daily intrusion into the personal lives and privacy of citizens, leading to significant disruption and eroding the quality of mobile communication.
---
---
A. Enactment of a Comprehensive Data Protection Act (Inspired by Global Standards)
**A. Enactment of a Comprehensive Data Protection Act (Inspired by Global Standards)**
We respectfully request the People's Majlis to initiate, deliberate upon, and enact a dedicated Data Protection Act that establishes high standards for the lawful processing of personal data, drawing upon globally recognized best practices, such as the European Unions General Data Protection Regulation (GDPR), to ensure the Maldives framework is robust and future-proof.
1 . The Act must include, but not be limited to, the following core principles:
**1. The Act must include, but not be limited to, the following core principles:**
- Fundamental Rights of the Data Subject: Guaranteeing the rights of individuals to:
- Access their personal data.
- Rectify inaccurate data.
- Erase their data (Right to be Forgotten).
- Portability of their data.
- **Fundamental Rights of the Data Subject:** Guaranteeing the rights of individuals to:
- Access their personal data.
- Rectify inaccurate data.
- Erase their data (Right to be Forgotten).
- Portability of their data.
2 . Lawful Basis for Processing: Mandating that all processing of personal data must be based on a clear, explicit, and informed consent or other defined legal grounds. The use of pre-checked boxes or presumed consent must be prohibited.
**2. Lawful Basis for Processing:**
3 . Specific Mandate on Electronic Marketing Consent (Unsolicited SMS):
Mandating that all processing of personal data must be based on a clear, explicit, and informed consent or other defined legal grounds. The use of pre-checked boxes or presumed consent must be prohibited.
**3. Specific Mandate on Electronic Marketing Consent (Unsolicited SMS):**
- The Act must specifically define the use of personal data (such as a phone number) for promotional SMS or electronic messaging as a form of marketing that requires prior, specific, and informed Opt-In consent from the subscriber.
@@ -91,15 +88,19 @@ We respectfully request the People's Majlis to initiate, deliberate upon, and en
- The Act must prohibit the sending of commercial, marketing, or promotional SMS and messages to any subscriber without this specific consent.
4 . Establishment of an Independent Authority: Creating a well-resourced and independent Data Protection Authority (DPA) with the power to:
**4. Establishment of an Independent Authority:**
Creating a well-resourced and independent Data Protection Authority (DPA) with the power to:
- Enforce the Act.
- Investigate complaints.
- Impose significant and effective penalties for non-compliance.
4 . Data Breach Obligations: Making it mandatory for data controllers to promptly notify the DPA and affected individuals of any significant data breach.
**5. Data Breach Obligations:**
---
Making it mandatory for data controllers to promptly notify the DPA and affected individuals of any significant data breach.
---
We affirm that this petition is in full compliance with the Rules of Procedure of the People's Majlis (Article 257) and does not contain any of the prohibited content, including, but not limited to: anything contrary to the Constitution or laws of the Republic of Maldives; confidential business or financial information; any request to grant or strip honors, or give or dismiss employment; or anything that endangers national security.