├── 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 |
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 |
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 |
393 |
394 |
395 |
396 |
397 |
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 |
434 |
435 |
436 | );
437 | }
438 |
--------------------------------------------------------------------------------