├── bun.lockb ├── public ├── favicon.png ├── icon192.png ├── icon512.png └── preview-image.jpg ├── src ├── assets │ ├── 10sen.png │ ├── 20sen.png │ ├── 50sen.png │ ├── 5sen.png │ ├── clean.png │ ├── coin.mp3 │ ├── dice.png │ ├── jahit.png │ ├── meja.png │ ├── swish.mp3 │ ├── undo.mp3 │ ├── shuffle.mp3 │ ├── writing.png │ ├── keypress.mp3 │ ├── myr1_small.png │ ├── myr5_small.png │ ├── myr100_small.png │ ├── myr10_small.png │ ├── myr20_small.png │ └── myr50_small.png ├── main.jsx ├── ModalAbout.jsx ├── MoneyStack.jsx ├── Pill.jsx ├── ModalAmount.jsx ├── index.css ├── utils.js ├── ButtoAdd.jsx ├── ModalSummary.jsx └── App.jsx ├── postcss.config.js ├── tailwind.config.js ├── .gitignore ├── package.json ├── vite.config.js ├── eslint.config.js ├── README.md └── index.html /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/bun.lockb -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/public/favicon.png -------------------------------------------------------------------------------- /public/icon192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/public/icon192.png -------------------------------------------------------------------------------- /public/icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/public/icon512.png -------------------------------------------------------------------------------- /src/assets/10sen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/10sen.png -------------------------------------------------------------------------------- /src/assets/20sen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/20sen.png -------------------------------------------------------------------------------- /src/assets/50sen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/50sen.png -------------------------------------------------------------------------------- /src/assets/5sen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/5sen.png -------------------------------------------------------------------------------- /src/assets/clean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/clean.png -------------------------------------------------------------------------------- /src/assets/coin.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/coin.mp3 -------------------------------------------------------------------------------- /src/assets/dice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/dice.png -------------------------------------------------------------------------------- /src/assets/jahit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/jahit.png -------------------------------------------------------------------------------- /src/assets/meja.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/meja.png -------------------------------------------------------------------------------- /src/assets/swish.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/swish.mp3 -------------------------------------------------------------------------------- /src/assets/undo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/undo.mp3 -------------------------------------------------------------------------------- /src/assets/shuffle.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/shuffle.mp3 -------------------------------------------------------------------------------- /src/assets/writing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/writing.png -------------------------------------------------------------------------------- /public/preview-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/public/preview-image.jpg -------------------------------------------------------------------------------- /src/assets/keypress.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/keypress.mp3 -------------------------------------------------------------------------------- /src/assets/myr1_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/myr1_small.png -------------------------------------------------------------------------------- /src/assets/myr5_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/myr5_small.png -------------------------------------------------------------------------------- /src/assets/myr100_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/myr100_small.png -------------------------------------------------------------------------------- /src/assets/myr10_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/myr10_small.png -------------------------------------------------------------------------------- /src/assets/myr20_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/myr20_small.png -------------------------------------------------------------------------------- /src/assets/myr50_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mansarip/wang-malaysia/HEAD/src/assets/myr50_small.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import tailwindScrollbar from "tailwind-scrollbar"; 2 | 3 | export default { 4 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [tailwindScrollbar], 9 | }; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .vercel 26 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { registerSW } from "virtual:pwa-register"; 4 | import "./index.css"; 5 | import App from "./App.jsx"; 6 | 7 | const updateSW = registerSW({ 8 | onNeedRefresh() { 9 | if (confirm("New version available. Reload?")) { 10 | updateSW(true); 11 | } 12 | }, 13 | }); 14 | 15 | createRoot(document.getElementById("root")).render( 16 | 17 | 18 | 19 | ); 20 | -------------------------------------------------------------------------------- /src/ModalAbout.jsx: -------------------------------------------------------------------------------- 1 | export default function ModalAbout({ close }) { 2 | return ( 3 |
7 |
{ 10 | e.stopPropagation(); 11 | }} 12 | > 13 |
Dicipta & disusun oleh
14 |
Luqman B. Shariffudin
15 |
16 | 17 | Source code (Github) 18 | 19 |
20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/MoneyStack.jsx: -------------------------------------------------------------------------------- 1 | export default function MoneyStack({ 2 | type, 3 | count, 4 | onClick, 5 | imageUrl, 6 | width, 7 | height, 8 | border, 9 | topOffset, 10 | leftOffset, 11 | isRounded, 12 | isCoin = false, 13 | }) { 14 | return Array.from({ length: count }, (_, index) => { 15 | let animation = isCoin ? "coin-drop" : "paper-drop"; 16 | let radius = isRounded ? "rounded-full" : ""; 17 | 18 | return ( 19 |
onClick(type)} 22 | className={`shadow-[2px_2px_3px_#1e1e1e] absolute bg-no-repeat bg-contain ${animation} ${radius}`} 23 | style={{ 24 | backgroundImage: `url(${imageUrl})`, 25 | width, 26 | height, 27 | border, 28 | top: `${topOffset - index * 2.2}%`, 29 | left: `${leftOffset - index * 2.5}%`, 30 | }} 31 | /> 32 | ); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gameduit-react", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^18.3.1", 14 | "react-dom": "^18.3.1" 15 | }, 16 | "devDependencies": { 17 | "@eslint/js": "^9.15.0", 18 | "@types/react": "^18.3.12", 19 | "@types/react-dom": "^18.3.1", 20 | "@vitejs/plugin-react-swc": "^3.5.0", 21 | "autoprefixer": "^10.4.20", 22 | "eslint": "^9.15.0", 23 | "eslint-plugin-react": "^7.37.2", 24 | "eslint-plugin-react-hooks": "^5.0.0", 25 | "eslint-plugin-react-refresh": "^0.4.14", 26 | "globals": "^15.12.0", 27 | "postcss": "^8.4.49", 28 | "tailwind-scrollbar": "^3.1.0", 29 | "tailwindcss": "^3.4.16", 30 | "vite": "^6.0.1", 31 | "vite-plugin-pwa": "^0.21.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react-swc"; 3 | import { VitePWA } from "vite-plugin-pwa"; 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | VitePWA({ 10 | workbox: { 11 | globPatterns: ["**/*"], 12 | }, 13 | includeAssets: ["**/*"], 14 | manifest: { 15 | short_name: "Wang", 16 | name: "Visual Wang Malaysia", 17 | description: 18 | "Alat interaktif untuk memvisualkan amaun wang dengan grafik wang kertas dan syiling Malaysia.", 19 | icons: [ 20 | { 21 | src: "icon192.png", 22 | sizes: "192x192", 23 | type: "image/png", 24 | }, 25 | { 26 | src: "icon512.png", 27 | sizes: "512x512", 28 | type: "image/png", 29 | }, 30 | ], 31 | start_url: ".", 32 | display: "standalone", 33 | theme_color: "#d6c8af", 34 | background_color: "#ffffff", 35 | }, 36 | }), 37 | ], 38 | build: { 39 | assetsDir: "", 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import react from "eslint-plugin-react"; 4 | import reactHooks from "eslint-plugin-react-hooks"; 5 | import reactRefresh from "eslint-plugin-react-refresh"; 6 | 7 | export default [ 8 | { ignores: ["dist"] }, 9 | { 10 | files: ["**/*.{js,jsx}"], 11 | languageOptions: { 12 | ecmaVersion: 2020, 13 | globals: globals.browser, 14 | parserOptions: { 15 | ecmaVersion: "latest", 16 | ecmaFeatures: { jsx: true }, 17 | sourceType: "module", 18 | }, 19 | }, 20 | settings: { react: { version: "18.3" } }, 21 | plugins: { 22 | react, 23 | "react-hooks": reactHooks, 24 | "react-refresh": reactRefresh, 25 | }, 26 | rules: { 27 | ...js.configs.recommended.rules, 28 | ...react.configs.recommended.rules, 29 | ...react.configs["jsx-runtime"].rules, 30 | ...reactHooks.configs.recommended.rules, 31 | "react/jsx-no-target-blank": "off", 32 | "react/prop-types": "off", 33 | "react-refresh/only-export-components": [ 34 | "warn", 35 | { allowConstantExport: true }, 36 | ], 37 | }, 38 | }, 39 | ]; 40 | -------------------------------------------------------------------------------- /src/Pill.jsx: -------------------------------------------------------------------------------- 1 | export default function Pill({ type, unit = 1 }) { 2 | const types = { 3 | 0.05: { 4 | text: "RM0.05", 5 | color: "text-slate-800", 6 | subColor: "text-slate-700", 7 | }, 8 | 0.1: { 9 | text: "RM0.10", 10 | color: "text-slate-800", 11 | subColor: "text-slate-700", 12 | }, 13 | 0.2: { 14 | text: "RM0.20", 15 | color: "text-yellow-800", 16 | subColor: "text-yellow-700", 17 | }, 18 | 0.5: { 19 | text: "RM0.50", 20 | color: "text-yellow-800", 21 | subColor: "text-yellow-700", 22 | }, 23 | 1: { text: "RM1", color: "text-sky-800", subColor: "text-sky-700" }, 24 | 5: { text: "RM5", color: "text-green-800", subColor: "text-green-700" }, 25 | 10: { text: "RM10", color: "text-red-800", subColor: "text-red-700" }, 26 | 20: { text: "RM20", color: "text-yellow-800", subColor: "text-yellow-700" }, 27 | 50: { text: "RM50", color: "text-teal-800", subColor: "text-teal-700" }, 28 | 100: { 29 | text: "RM100", 30 | color: "text-purple-800", 31 | subColor: "text-purple-700", 32 | }, 33 | }; 34 | 35 | const pillType = types[type]; 36 | 37 | if (!pillType) return null; 38 | if (!unit) return null; 39 | 40 | return ( 41 |
42 | ({pillType.text} 43 | ×{unit}) 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/ModalAmount.jsx: -------------------------------------------------------------------------------- 1 | export default function ModalAmount({ close, submit }) { 2 | const preSubmit = (e) => { 3 | e.preventDefault(); 4 | let n = Number(e.target.amount.value); 5 | if (isNaN(n)) return; 6 | if (n > 10000) { 7 | alert( 8 | "Kami belum boleh kendali amaun yang besar. Nanti crash. Harap maaf" 9 | ); 10 | return; 11 | } 12 | submit(n); 13 | }; 14 | 15 | return ( 16 |
20 |
{ 23 | e.stopPropagation(); 24 | }} 25 | > 26 |
MASUKKAN AMAUN (RM)
27 |
Enter amount
28 |
29 | 37 | 43 |
44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Duit Malaysia 2 | 3 | **Duit Malaysia** adalah satu tool interaktif untuk menggambarkan nilai wang dalam bentuk visual, seperti wang kertas yang disusun atas meja. Projek ini bertujuan untuk membantu pengguna memahami dan memvisualkan nilai wang dengan mudah dan menyeronokkan. 4 | 5 | (Thanks, GPT 😘) 6 | 7 | ## Ciri-ciri 8 | 9 | 1. **Visualisasi Wang** 10 | 11 | - Memaparkan nilai wang dalam bentuk visual (wang kertas Malaysia). 12 | 13 | 1. **Fungsi Shuffle** 14 | 15 | - Nilai wang yang sama tetapi dengan denominasi yang berbeza. 16 | 17 | 1. **Masukkan Amount** 18 | 19 | - Pengguna boleh masukkan jumlah wang dan ia akan dipaparkan secara visual. 20 | 21 | 1. **Butang Individu untuk Wang** 22 | 23 | - Butang untuk menambah wang kertas tertentu secara manual. 24 | 25 | 1. **Fungsi Clear** 26 | 27 | - Untuk mengosongkan visual wang. 28 | 29 | 1. **PWA** 30 | 31 | - Boleh install terus jadi app dalam phone. 32 | 33 | ## Akses 34 | 35 | Boleh guna terus melalui [https://wang.mansarip.org](https://wang.mansarip.org) 36 | 37 | ## Development 38 | 39 | Dibuat guna React + Vite, with Bun 40 | 41 | **Clone** 42 | ``` 43 | git@github.com:mansarip/wang-malaysia.git 44 | ``` 45 | 46 | **Start dev** 47 | ``` 48 | cd wang-malaysia 49 | bun i 50 | bun run dev 51 | ``` 52 | 53 | Jika ada apa-apa penambahan, boleh submit PR, terima kasih! 54 | 55 | # Todo 56 | 57 | - ~~Tambah duit syiling atas meja~~ 58 | - ~~Handle duit yang banyak (berjuta) - sebab dia crash bila banyak sangat component rendered~~ 59 | - Handle long press 60 | - Paparan amaun atas duit 61 | - Modal ringkasan 62 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Delius+Unicase:wght@700&display=swap"); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | * { 8 | user-select: none; 9 | } 10 | 11 | .delius-unicase-bold { 12 | font-family: "Delius Unicase", cursive; 13 | font-weight: 700; 14 | font-style: normal; 15 | } 16 | 17 | img { 18 | -webkit-touch-callout: none; /* Halang menu konteks pada iOS */ 19 | -webkit-user-select: none; /* Halang teks dipilih pada iOS */ 20 | -webkit-tap-highlight-color: transparent; /* Hilangkan highlight */ 21 | user-select: none; /* Halang teks dipilih */ 22 | pointer-events: none; /* Halang interaksi dengan imej */ 23 | } 24 | 25 | input::-webkit-outer-spin-button, 26 | input::-webkit-inner-spin-button { 27 | -webkit-appearance: none; 28 | margin: 0; 29 | } 30 | input[type="number"] { 31 | -moz-appearance: textfield; 32 | } 33 | 34 | @keyframes paperDrop { 35 | 0% { 36 | transform: translateY(-100px) rotate(-15deg); 37 | opacity: 0; 38 | } 39 | 50% { 40 | transform: translateY(10px) rotate(5deg); 41 | opacity: 0.7; 42 | } 43 | 100% { 44 | transform: translateY(0) rotate(0deg); 45 | opacity: 1; 46 | } 47 | } 48 | 49 | .paper-drop { 50 | animation: paperDrop 0.2s ease-out forwards; 51 | } 52 | 53 | @keyframes coinDrop { 54 | 0% { 55 | transform: perspective(500px) translateZ(-100px) rotateX(-15deg); 56 | opacity: 0; 57 | } 58 | 50% { 59 | transform: perspective(500px) translateZ(10px) rotateX(5deg); 60 | opacity: 0.7; 61 | } 62 | 100% { 63 | transform: perspective(500px) translateZ(0) rotateX(0deg); 64 | opacity: 1; 65 | } 66 | } 67 | 68 | .coin-drop { 69 | animation: coinDrop 0.2s ease-out forwards; 70 | } 71 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 16 | 17 | 18 | 22 | 26 | 30 | 31 | 32 | 33 | 37 | 41 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | Visual Wang - Alat Interaktif untuk Visualisasi Amaun Wang 53 | 54 | 55 |
56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const denominations = [ 2 | { 3 | id: "myr1", 4 | n: 1, 5 | }, 6 | { 7 | id: "myr5", 8 | n: 5, 9 | }, 10 | { 11 | id: "myr10", 12 | n: 10, 13 | }, 14 | { 15 | id: "myr20", 16 | n: 20, 17 | }, 18 | { 19 | id: "myr50", 20 | n: 50, 21 | }, 22 | { 23 | id: "myr100", 24 | n: 100, 25 | }, 26 | { 27 | id: "sen5", 28 | n: 0.05, 29 | isCoin: true, 30 | }, 31 | { 32 | id: "sen10", 33 | n: 0.1, 34 | isCoin: true, 35 | }, 36 | { 37 | id: "sen20", 38 | n: 0.2, 39 | isCoin: true, 40 | }, 41 | { 42 | id: "sen50", 43 | n: 0.5, 44 | isCoin: true, 45 | }, 46 | ]; 47 | 48 | const denominationMap = denominations.reduce((map, item) => { 49 | map[item.id] = item.n; 50 | return map; 51 | }, {}); 52 | 53 | export function calculate(stack) { 54 | let amt = Object.keys(stack).reduce((total, key) => { 55 | const value = stack[key] || 0; 56 | return total + (denominationMap[key] || 0) * value; 57 | }, 0); 58 | 59 | return amt; 60 | } 61 | 62 | export function randomizeBreakdown(amount) { 63 | const denominations = [ 64 | // duit kertas dahulu 65 | { key: "myr100", value: 10000 }, 66 | { key: "myr50", value: 5000 }, 67 | { key: "myr20", value: 2000 }, 68 | { key: "myr10", value: 1000 }, 69 | { key: "myr5", value: 500 }, 70 | { key: "myr1", value: 100 }, 71 | // kemudian syiling 72 | { key: "sen50", value: 50 }, 73 | { key: "sen20", value: 20 }, 74 | { key: "sen10", value: 10 }, 75 | { key: "sen5", value: 5 }, 76 | ]; 77 | 78 | const result = { 79 | sen50: 0, 80 | sen20: 0, 81 | sen10: 0, 82 | sen5: 0, 83 | myr1: 0, 84 | myr5: 0, 85 | myr10: 0, 86 | myr20: 0, 87 | myr50: 0, 88 | myr100: 0, 89 | }; 90 | 91 | // tukar amount ke unit sen 92 | amount = Math.round(amount * 100); 93 | 94 | // duit kertas dahulu 95 | let paperDenominations = denominations.filter((d) => d.value >= 100); 96 | while (amount >= 100) { 97 | // selagi baki lebih daripada atau sama dengan rm1 98 | const validDenominations = paperDenominations.filter( 99 | (d) => d.value <= amount 100 | ); 101 | const randomIndex = Math.floor(Math.random() * validDenominations.length); 102 | const denomination = validDenominations[randomIndex]; 103 | 104 | result[denomination.key]++; 105 | amount -= denomination.value; 106 | } 107 | 108 | // kemudian duit syiling 109 | let coinDenominations = denominations.filter((d) => d.value < 100); 110 | while (amount > 0) { 111 | // untuk baki kurang daripada rm1 112 | const validDenominations = coinDenominations.filter( 113 | (d) => d.value <= amount 114 | ); 115 | const randomIndex = Math.floor(Math.random() * validDenominations.length); 116 | const denomination = validDenominations[randomIndex]; 117 | 118 | result[denomination.key]++; 119 | amount -= denomination.value; 120 | } 121 | 122 | return result; 123 | } 124 | 125 | export function formatCurrency(amount, decimal = false) { 126 | return new Intl.NumberFormat("ms-MY", { 127 | minimumFractionDigits: decimal ? 2 : 0, 128 | maximumFractionDigits: decimal ? 2 : 0, 129 | }).format(amount); 130 | } 131 | -------------------------------------------------------------------------------- /src/ButtoAdd.jsx: -------------------------------------------------------------------------------- 1 | import myr1 from "./assets/myr1_small.png"; 2 | import myr5 from "./assets/myr5_small.png"; 3 | import myr10 from "./assets/myr10_small.png"; 4 | import myr20 from "./assets/myr20_small.png"; 5 | import myr50 from "./assets/myr50_small.png"; 6 | import myr100 from "./assets/myr100_small.png"; 7 | import sen50 from "./assets/50sen.png"; 8 | import sen20 from "./assets/20sen.png"; 9 | import sen10 from "./assets/10sen.png"; 10 | import sen5 from "./assets/5sen.png"; 11 | 12 | const types = { 13 | 1: { 14 | text: "RM 1", 15 | bgColor: "bg-sky-800", 16 | borderColor: "border-sky-900", 17 | image: myr1, 18 | }, 19 | 5: { 20 | text: "RM 5", 21 | bgColor: "bg-green-800", 22 | borderColor: "border-green-900", 23 | image: myr5, 24 | }, 25 | 10: { 26 | text: "RM 10", 27 | bgColor: "bg-red-800", 28 | borderColor: "border-red-900", 29 | image: myr10, 30 | }, 31 | 20: { 32 | text: "RM 20", 33 | bgColor: "bg-yellow-800", 34 | borderColor: "border-yellow-900", 35 | image: myr20, 36 | }, 37 | 50: { 38 | text: "RM 50", 39 | bgColor: "bg-teal-800", 40 | borderColor: "border-teal-900", 41 | image: myr50, 42 | }, 43 | 100: { 44 | text: "RM 100", 45 | bgColor: "bg-purple-800", 46 | borderColor: "border-purple-900", 47 | image: myr100, 48 | }, 49 | 0.5: { 50 | text: "50 sen", 51 | bgColor: "bg-yellow-800", 52 | borderColor: "border-yellow-900", 53 | image: sen50, 54 | isSyiling: true, 55 | }, 56 | 0.2: { 57 | text: "20 sen", 58 | bgColor: "bg-yellow-800", 59 | borderColor: "border-yellow-900", 60 | image: sen20, 61 | isSyiling: true, 62 | }, 63 | 0.1: { 64 | text: "10 sen", 65 | bgColor: "bg-slate-700", 66 | borderColor: "border-slate-800", 67 | image: sen10, 68 | isSyiling: true, 69 | }, 70 | 0.05: { 71 | text: "5 sen", 72 | bgColor: "bg-slate-700", 73 | borderColor: "border-slate-800", 74 | image: sen5, 75 | isSyiling: true, 76 | }, 77 | }; 78 | 79 | export default function ButtonAdd({ type, onClick }) { 80 | const buttonType = types[type]; 81 | 82 | if (!buttonType) return null; 83 | 84 | return ( 85 |
89 | {buttonType.isSyiling ? ( 90 | <> 91 | 95 |
98 | {buttonType.text} 99 |
100 | 101 | ) : ( 102 | <> 103 | 107 |
110 | {buttonType.text} 111 |
112 | 113 | )} 114 |
115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /src/ModalSummary.jsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | import myr1 from "./assets/myr1_small.png"; 3 | import myr5 from "./assets/myr5_small.png"; 4 | import myr10 from "./assets/myr10_small.png"; 5 | import myr20 from "./assets/myr20_small.png"; 6 | import myr50 from "./assets/myr50_small.png"; 7 | import myr100 from "./assets/myr100_small.png"; 8 | import sen50 from "./assets/50sen.png"; 9 | import sen20 from "./assets/20sen.png"; 10 | import sen10 from "./assets/10sen.png"; 11 | import sen5 from "./assets/5sen.png"; 12 | import { formatCurrency } from "./utils"; 13 | 14 | const types = { 15 | 0.05: { 16 | text: "5 Sen", 17 | color: "text-slate-800", 18 | subColor: "text-slate-700", 19 | image: sen5, 20 | }, 21 | 0.1: { 22 | text: "10 Sen", 23 | color: "text-slate-800", 24 | subColor: "text-slate-700", 25 | image: sen10, 26 | }, 27 | 0.2: { 28 | text: "20 Sen", 29 | color: "text-yellow-800", 30 | subColor: "text-yellow-700", 31 | image: sen20, 32 | }, 33 | 0.5: { 34 | text: "50 Sen", 35 | color: "text-yellow-800", 36 | subColor: "text-yellow-700", 37 | image: sen50, 38 | }, 39 | 1: { 40 | text: "RM 1", 41 | color: "text-sky-800", 42 | subColor: "text-sky-700", 43 | image: myr1, 44 | }, 45 | 5: { 46 | text: "RM 5", 47 | color: "text-green-800", 48 | subColor: "text-green-700", 49 | image: myr5, 50 | }, 51 | 10: { 52 | text: "RM 10", 53 | color: "text-red-800", 54 | subColor: "text-red-700", 55 | image: myr10, 56 | }, 57 | 20: { 58 | text: "RM 20", 59 | color: "text-yellow-800", 60 | subColor: "text-yellow-700", 61 | image: myr20, 62 | }, 63 | 50: { 64 | text: "RM 50", 65 | color: "text-teal-800", 66 | subColor: "text-teal-700", 67 | image: myr50, 68 | }, 69 | 100: { 70 | text: "RM 100", 71 | color: "text-purple-800", 72 | subColor: "text-purple-700", 73 | image: myr100, 74 | }, 75 | }; 76 | 77 | export default function ModalSummary({ 78 | close, 79 | stack = {}, 80 | denominations = [], 81 | total = 0, 82 | }) { 83 | return ( 84 |
88 |
{ 91 | e.stopPropagation(); 92 | }} 93 | > 94 |
95 | RINGKASAN 96 | 102 |
103 |
104 |
105 | {denominations.map((d) => { 106 | let qty = stack[d.id] || 0; 107 | if (!qty) { 108 | return; 109 | } 110 | 111 | let row = types[d.n]; 112 | if (!row) return; 113 | 114 | row.value = d.n; 115 | row.id = d.id; 116 | row.quantity = qty; 117 | row.isCoin = d.isCoin || false; 118 | 119 | let total = row.value * row.quantity; 120 | 121 | return ( 122 | 123 |
124 | {row.isCoin ? ( 125 | 126 | ) : ( 127 | 128 | )} 129 |
130 |
{row.text}
131 |
× {row.quantity}
132 |
=
133 |
134 | {formatCurrency(total, row.isCoin)} 135 |
136 |
137 | ); 138 | })} 139 |
140 |
141 |
142 | Jumlah:{" "} 143 | RM {formatCurrency(total, true)} 144 |
145 |
146 |
147 | ); 148 | } 149 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import ButtonAdd from "./ButtoAdd"; 3 | import { 4 | calculate, 5 | denominations, 6 | formatCurrency, 7 | randomizeBreakdown, 8 | } from "./utils"; 9 | import ModalAmount from "./ModalAmount"; 10 | import ModalAbout from "./ModalAbout"; 11 | import ModalSummary from "./ModalSummary"; 12 | import MoneyStack from "./MoneyStack"; 13 | 14 | import undoSound from "./assets/undo.mp3"; 15 | import keypressSound from "./assets/keypress.mp3"; 16 | import swishSound from "./assets/swish.mp3"; 17 | import shuffleSound from "./assets/shuffle.mp3"; 18 | import coinSound from "./assets/coin.mp3"; 19 | import iconWriting from "./assets/writing.png"; 20 | import mejaImage from "./assets/meja.png"; 21 | import myr1 from "./assets/myr1_small.png"; 22 | import myr5 from "./assets/myr5_small.png"; 23 | import myr10 from "./assets/myr10_small.png"; 24 | import myr20 from "./assets/myr20_small.png"; 25 | import myr50 from "./assets/myr50_small.png"; 26 | import myr100 from "./assets/myr100_small.png"; 27 | import sen50 from "./assets/50sen.png"; 28 | import sen20 from "./assets/20sen.png"; 29 | import sen10 from "./assets/10sen.png"; 30 | import sen5 from "./assets/5sen.png"; 31 | import iconShuffle from "./assets/dice.png"; 32 | import jahitPattern from "./assets/jahit.png"; 33 | import iconClear from "./assets/clean.png"; 34 | 35 | const LS_KEY_STORE = "stack"; 36 | 37 | function defaultStack() { 38 | return { 39 | myr1: 0, 40 | myr5: 0, 41 | myr10: 0, 42 | myr20: 0, 43 | myr50: 0, 44 | myr100: 0, 45 | sen50: 0, 46 | sen20: 0, 47 | sen10: 0, 48 | sen5: 0, 49 | }; 50 | } 51 | 52 | function loadStack() { 53 | const saved = localStorage.getItem(LS_KEY_STORE); 54 | try { 55 | if (!saved) { 56 | return defaultStack(); 57 | } 58 | 59 | return JSON.parse(saved); 60 | } catch (err) { 61 | console.error(err?.message); 62 | return defaultStack(); 63 | } 64 | } 65 | 66 | export default function App() { 67 | const undoSoundRef = useRef(null); 68 | const pressSoundRef = useRef(null); 69 | const swishSoundRef = useRef(null); 70 | const shuffleSoundRef = useRef(null); 71 | const coinSoundRef = useRef(null); 72 | 73 | const [showModalAmount, setShowModalAmount] = useState(false); 74 | const [showModalAbout, setShowModalAbout] = useState(false); 75 | const [showModalSummary, setShowModalSummary] = useState(false); 76 | const [total, setTotal] = useState(0); 77 | const [stack, setStack] = useState(loadStack()); 78 | 79 | const playSwishSound = () => { 80 | if (swishSoundRef.current) { 81 | swishSoundRef.current.currentTime = 0; 82 | swishSoundRef.current.play(); 83 | } 84 | }; 85 | 86 | const playButtonSound = () => { 87 | if (pressSoundRef.current) { 88 | pressSoundRef.current.currentTime = 0; 89 | pressSoundRef.current.play(); 90 | } 91 | }; 92 | 93 | const playShuffleSound = () => { 94 | if (shuffleSoundRef.current) { 95 | shuffleSoundRef.current.currentTime = 0; 96 | shuffleSoundRef.current.play(); 97 | } 98 | }; 99 | 100 | const playCoinSound = () => { 101 | if (coinSoundRef.current) { 102 | coinSoundRef.current.currentTime = 0; 103 | coinSoundRef.current.play(); 104 | } 105 | }; 106 | 107 | const clearDesk = () => { 108 | playSwishSound(); 109 | setStack(defaultStack()); 110 | }; 111 | 112 | const tambah = (key, isCoin = false) => { 113 | if (isCoin) { 114 | playCoinSound(); 115 | } else { 116 | playButtonSound(); 117 | } 118 | 119 | setStack({ 120 | ...stack, 121 | [key]: stack[key] + 1, 122 | }); 123 | }; 124 | 125 | const tolak = (key) => { 126 | playSwishSound(); 127 | setStack({ 128 | ...stack, 129 | [key]: stack[key] - 1, 130 | }); 131 | }; 132 | 133 | const shuffle = () => { 134 | if (total <= 0) return; 135 | 136 | playShuffleSound(); 137 | let newStack = randomizeBreakdown(total); 138 | 139 | // prevent infinite loop (kalau lebih rm5 baru cek) <-- benda ni kita remove bila implement duit syiling 140 | if (total >= 5) { 141 | let str1 = JSON.stringify(newStack); 142 | let str2 = JSON.stringify(stack); 143 | 144 | // jaminan supaya hasil shuffle tak sama dengan yang sebelumnya 145 | while (str1 === str2) { 146 | newStack = randomizeBreakdown(total); 147 | str1 = JSON.stringify(newStack); 148 | } 149 | } 150 | 151 | setStack(newStack); 152 | }; 153 | 154 | const paparAmaun = (value) => { 155 | let stack = randomizeBreakdown(value); 156 | setStack(stack); 157 | playButtonSound(); 158 | setShowModalAmount(false); 159 | }; 160 | 161 | const openModalSummary = () => { 162 | setShowModalSummary(true); 163 | playShuffleSound(); 164 | }; 165 | 166 | const closeModalSummary = () => { 167 | setShowModalSummary(false); 168 | playSwishSound(); 169 | }; 170 | 171 | useEffect(() => { 172 | setTotal(calculate(stack)); 173 | localStorage.setItem(LS_KEY_STORE, JSON.stringify(stack)); 174 | }, [stack]); 175 | 176 | return ( 177 |
178 |
182 | {showModalAmount && ( 183 | setShowModalAmount(false)} 185 | submit={paparAmaun} 186 | /> 187 | )} 188 | 189 | {showModalAbout && ( 190 | setShowModalAbout(false)} /> 191 | )} 192 | 193 | {showModalSummary && ( 194 | 200 | )} 201 | 202 |
203 | 209 | 210 | 216 | 217 |
218 | 219 | RM {formatCurrency(total, true)} 220 | 221 | {total > 0 && ( 222 | 228 | )} 229 |
230 |
231 |
232 | 243 | 254 | 265 | 276 | 287 | 298 | 311 | 324 | 337 | 350 |
351 | 352 | 357 | 358 |
362 | 363 | {total > 0 && ( 364 | <> 365 | 371 | 372 | )} 373 | 374 | 380 |
381 |
382 | {denominations.map((item) => ( 383 | tambah(item.id, item.isCoin)} 387 | /> 388 | ))} 389 |
390 |
391 | 392 |
398 | 399 |
400 |
401 |
402 | 403 | Rombak visual 404 |
405 | 406 |
407 | 408 | Padam semula 409 |
410 | 411 |
412 | 413 | Amaun sendiri 414 |
415 |
416 |
417 | Oleh Man Sarip - Telegram: 418 | 423 | @mansarip 424 | {" "} 425 | -{" "} 426 | 431 | Github 432 | 433 |
434 |
435 |
436 | ); 437 | } 438 | --------------------------------------------------------------------------------