mirror of
https://github.com/MvDevsUnion/WPetition.git
synced 2026-01-18 19:39:29 +00:00
react frontend
This commit is contained in:
24
frontend-react/.gitignore
vendored
Normal file
24
frontend-react/.gitignore
vendored
Normal 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
73
frontend-react/README.md
Normal 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...
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
22
frontend-react/components.json
Normal file
22
frontend-react/components.json
Normal 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": {}
|
||||
}
|
||||
23
frontend-react/eslint.config.js
Normal file
23
frontend-react/eslint.config.js
Normal 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
19
frontend-react/index.html
Normal 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
4724
frontend-react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
frontend-react/package.json
Normal file
49
frontend-react/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
frontend-react/public/fonts/A_Waheed.otf
Normal file
BIN
frontend-react/public/fonts/A_Waheed.otf
Normal file
Binary file not shown.
BIN
frontend-react/public/fonts/Faruma.ttf
Normal file
BIN
frontend-react/public/fonts/Faruma.ttf
Normal file
Binary file not shown.
BIN
frontend-react/public/fonts/MVWaheed.otf
Normal file
BIN
frontend-react/public/fonts/MVWaheed.otf
Normal file
Binary file not shown.
BIN
frontend-react/public/fonts/utheem.ttf
Normal file
BIN
frontend-react/public/fonts/utheem.ttf
Normal file
Binary file not shown.
1
frontend-react/public/vite.svg
Normal file
1
frontend-react/public/vite.svg
Normal 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
105
frontend-react/src/App.tsx
Normal 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;
|
||||
1
frontend-react/src/assets/react.svg
Normal file
1
frontend-react/src/assets/react.svg
Normal 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 |
91
frontend-react/src/components/TweetModal.tsx
Normal file
91
frontend-react/src/components/TweetModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend-react/src/components/layout/ErrorState.tsx
Normal file
15
frontend-react/src/components/layout/ErrorState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
frontend-react/src/components/layout/LanguageSwitcher.tsx
Normal file
43
frontend-react/src/components/layout/LanguageSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
frontend-react/src/components/layout/LoadingState.tsx
Normal file
17
frontend-react/src/components/layout/LoadingState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend-react/src/components/petition/AuthorCard.tsx
Normal file
30
frontend-react/src/components/petition/AuthorCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
frontend-react/src/components/petition/PetitionBody.tsx
Normal file
44
frontend-react/src/components/petition/PetitionBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
frontend-react/src/components/petition/PetitionHeader.tsx
Normal file
44
frontend-react/src/components/petition/PetitionHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
286
frontend-react/src/components/signature/SignatureForm.tsx
Normal file
286
frontend-react/src/components/signature/SignatureForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend-react/src/components/signature/SignaturePad.tsx
Normal file
74
frontend-react/src/components/signature/SignaturePad.tsx
Normal 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";
|
||||
66
frontend-react/src/components/ui/alert.tsx
Normal file
66
frontend-react/src/components/ui/alert.tsx
Normal 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 };
|
||||
46
frontend-react/src/components/ui/badge.tsx
Normal file
46
frontend-react/src/components/ui/badge.tsx
Normal 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 };
|
||||
62
frontend-react/src/components/ui/button.tsx
Normal file
62
frontend-react/src/components/ui/button.tsx
Normal 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 };
|
||||
92
frontend-react/src/components/ui/card.tsx
Normal file
92
frontend-react/src/components/ui/card.tsx
Normal 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,
|
||||
};
|
||||
32
frontend-react/src/components/ui/checkbox.tsx
Normal file
32
frontend-react/src/components/ui/checkbox.tsx
Normal 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 };
|
||||
141
frontend-react/src/components/ui/dialog.tsx
Normal file
141
frontend-react/src/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
21
frontend-react/src/components/ui/input.tsx
Normal file
21
frontend-react/src/components/ui/input.tsx
Normal 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 };
|
||||
22
frontend-react/src/components/ui/label.tsx
Normal file
22
frontend-react/src/components/ui/label.tsx
Normal 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 };
|
||||
60
frontend-react/src/hooks/useLanguage.ts
Normal file
60
frontend-react/src/hooks/useLanguage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
55
frontend-react/src/hooks/usePetition.ts
Normal file
55
frontend-react/src/hooks/usePetition.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
199
frontend-react/src/index.css
Normal file
199
frontend-react/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
56
frontend-react/src/lib/api.ts
Normal file
56
frontend-react/src/lib/api.ts
Normal 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)",
|
||||
};
|
||||
}
|
||||
6
frontend-react/src/lib/utils.ts
Normal file
6
frontend-react/src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
10
frontend-react/src/main.tsx
Normal file
10
frontend-react/src/main.tsx
Normal 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>,
|
||||
);
|
||||
24
frontend-react/src/types/petition.ts
Normal file
24
frontend-react/src/types/petition.ts
Normal 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";
|
||||
34
frontend-react/tsconfig.app.json
Normal file
34
frontend-react/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
13
frontend-react/tsconfig.json
Normal file
13
frontend-react/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
26
frontend-react/tsconfig.node.json
Normal file
26
frontend-react/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
22
frontend-react/vite.config.ts
Normal file
22
frontend-react/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 Union’s 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user