├── .githooks └── prepush ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── docs ├── .gitignore ├── .vitepress │ └── config.js ├── Makefile ├── README.md ├── better-burgers │ ├── Item.re │ ├── dune │ └── index.md ├── better-sandwiches │ ├── Item.re │ ├── dune │ └── index.md ├── burger-discounts │ ├── Discount.re │ ├── DiscountTests.re │ ├── Item.re │ ├── dune │ └── index.md ├── celsius-converter-exception │ ├── Snippets.re │ ├── dune │ └── index.md ├── celsius-converter-option │ ├── Snippets.re │ ├── dune │ └── index.md ├── counter │ ├── Snippets.re │ ├── dune │ └── index.md ├── cram-tests │ ├── BurgerTests.re │ ├── Item.re │ ├── dune │ ├── duration-diff │ ├── index.md │ ├── initial-diff │ └── tests ├── discounts-lists │ ├── Discount.re │ ├── DiscountTests.re │ ├── Item.re │ ├── ListSafe.re │ ├── Order.re │ ├── dune │ ├── function-popup.png │ └── index.md ├── index.md ├── installation │ └── index.md ├── intro-to-dune │ └── index.md ├── intro │ └── index.md ├── numeric-types │ ├── Snippets.re │ ├── dune │ └── index.md ├── order-confirmation │ ├── Index.re │ ├── Item.re │ ├── Order.re │ ├── Snippets.re │ ├── dune │ └── index.md ├── order-with-promo │ ├── DateInput.re │ ├── Demo.re │ ├── Discount.re │ ├── DiscountTests.re │ ├── Index.re │ ├── Item.re │ ├── Order.re │ ├── Promo.re │ ├── RR.re │ ├── Types.re │ ├── dune │ ├── index.md │ └── type-error.txt ├── package-lock.json ├── package.json ├── promo-codes │ ├── Discount.re │ ├── DiscountTests.re │ ├── Item.re │ ├── dune │ └── index.md ├── promo-component │ ├── Discount.re │ ├── DiscountTests.re │ ├── Index.re │ ├── Item.re │ ├── Order.re │ ├── Promo.re │ ├── RR.re │ ├── dune │ └── index.md ├── sandwich-tests │ ├── Item.re │ ├── SandwichTests.re │ ├── dune │ └── index.md ├── styling-with-css │ ├── Order.re │ ├── dune │ └── index.md ├── syntaxes │ ├── cram.json │ ├── dune.json │ └── reason.json └── todo.md ├── dune ├── dune-project ├── index.html ├── melange-for-react-devs.opam ├── package-lock.json ├── package.json ├── src ├── better-burgers │ ├── Format.re │ ├── Index.re │ ├── Item.re │ ├── Order.re │ ├── dune │ ├── index.html │ ├── order-item.module.css │ └── order.module.css ├── better-sandwiches │ ├── Format.re │ ├── Index.re │ ├── Item.re │ ├── Order.re │ ├── dune │ ├── index.html │ ├── order-item.module.css │ └── order.module.css ├── burger-discounts │ ├── Array.re │ ├── BurgerTests.re │ ├── Discount.re │ ├── DiscountTests.re │ ├── Format.re │ ├── Index.re │ ├── Item.re │ ├── Order.re │ ├── SandwichTests.re │ ├── dune │ ├── index.html │ ├── order-item.module.css │ ├── order.module.css │ └── tests.t ├── celsius-converter-exception │ ├── CelsiusConverter.re │ ├── Index.re │ ├── dune │ └── index.html ├── celsius-converter-option │ ├── CelsiusConverter.re │ ├── CelsiusConverter_FloatFromString.re │ ├── Index.re │ ├── dune │ └── index.html ├── counter │ ├── Counter.re │ ├── Index.re │ ├── dune │ └── index.html ├── cram-tests │ ├── BurgerTests.re │ ├── Format.re │ ├── Index.re │ ├── Item.re │ ├── Order.re │ ├── SandwichTests.re │ ├── dune │ ├── index.html │ ├── order-item.module.css │ ├── order.module.css │ └── tests.t ├── discounts-lists │ ├── Array.re │ ├── BurgerTests.re │ ├── Discount.re │ ├── DiscountTests.re │ ├── Format.re │ ├── Index.re │ ├── Item.re │ ├── ListSafe.re │ ├── Order.re │ ├── SandwichTests.re │ ├── dune │ ├── index.html │ ├── order-item.module.css │ ├── order.module.css │ └── tests.t ├── numeric-types │ ├── Counter_Float.re │ ├── Index.re │ ├── dune │ └── index.html ├── order-confirmation │ ├── Format.re │ ├── Index.re │ ├── Item.re │ ├── Order.re │ ├── dune │ └── index.html ├── order-with-promo │ ├── Array.re │ ├── BurgerTests.re │ ├── DateInput.re │ ├── Demo.re │ ├── Discount.re │ ├── DiscountTests.re │ ├── Index.re │ ├── Item.re │ ├── ListSafe.re │ ├── Order.re │ ├── Promo.re │ ├── RR.re │ ├── SandwichTests.re │ ├── dune │ ├── index.html │ └── tests.t ├── promo-codes │ ├── Array.re │ ├── BurgerTests.re │ ├── Discount.re │ ├── DiscountTests.re │ ├── Format.re │ ├── Index.re │ ├── Item.re │ ├── ListSafe.re │ ├── Order.re │ ├── SandwichTests.re │ ├── dune │ ├── index.html │ ├── order-item.module.css │ ├── order.module.css │ └── tests.t ├── promo-component │ ├── Array.re │ ├── BurgerTests.re │ ├── Discount.re │ ├── DiscountTests.re │ ├── Index.re │ ├── Item.re │ ├── ListSafe.re │ ├── Order.re │ ├── Promo.re │ ├── RR.re │ ├── SandwichTests.re │ ├── dune │ ├── index.html │ └── tests.t ├── sandwich-tests │ ├── Format.re │ ├── Index.re │ ├── Item.re │ ├── Order.re │ ├── SandwichTests.re │ ├── dune │ ├── index.html │ ├── order-item.module.css │ └── order.module.css └── styling-with-css │ ├── Format.re │ ├── Index.re │ ├── Item.re │ ├── Order.re │ ├── dune │ ├── index.html │ ├── order-item.module.css │ └── order.module.css └── vite.config.mjs /.githooks/prepush: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if ! ( npm run format-check ); then 4 | echo "some files are not properly formatted, refusing to push" 5 | exit 1 6 | fi 7 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy VitePress site to Pages 2 | 3 | on: 4 | # Runs on pushes targeting the `main` branch. 5 | push: 6 | branches: [main] 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: write 14 | pages: write 15 | id-token: write 16 | 17 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 18 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 19 | concurrency: 20 | group: pages 21 | cancel-in-progress: false 22 | 23 | jobs: 24 | # Build job 25 | build: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 0 # Not needed if lastUpdated is not enabled 32 | - name: Setup Pages 33 | uses: actions/configure-pages@v4 34 | - name: Setup Node 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: 20 38 | cache: npm 39 | - uses: ocaml/setup-ocaml@v2 40 | with: 41 | ocaml-compiler: 5.1.1 42 | - name: Install all deps 43 | run: npm run install:npm-opam 44 | - name: Format check 45 | run: npm run format:check 46 | - name: Bundle the demo app 47 | run: npm run bundle 48 | - name: Install dependencies for VitePress 49 | run: | 50 | cd docs 51 | npm ci 52 | - name: Build with VitePress 53 | run: | 54 | cd docs 55 | npm run docs:build 56 | - name: Move demo site into docs dir 57 | run: mv dist docs/.vitepress/dist/demo 58 | - name: Deploy 59 | uses: peaceiris/actions-gh-pages@v3 60 | with: 61 | github_token: ${{ secrets.GITHUB_TOKEN }} 62 | publish_dir: ./docs/.vitepress/dist 63 | enable_jekyll: false 64 | force_orphan: true 65 | cname: react-book.melange.re 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | node_modules/ 4 | _build/ 5 | _opam/ 6 | dist/ 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ocaml.sandbox": { 3 | "kind": "opam", 4 | "switch": "${workspaceFolder:melange-for-react-devs}" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Melange 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Melange for React Developers 2 | 3 | Source code for the book [Melange for React 4 | Developers](https://react-book.melange.re/). 5 | 6 | ## Quick Start 7 | 8 | ```shell 9 | npm run init 10 | 11 | # In separate terminals: 12 | npm run watch 13 | npm run serve 14 | ``` 15 | 16 | ## Commands 17 | 18 | All the build commands are defined in the `scripts` field of `package.json`. 19 | This is completely optional, and other tools like `make` could be used. 20 | 21 | You can see all available commands by running `npm run`. There are explanations 22 | of each command in the `scriptsComments` field of the `package.json` file. Here 23 | are a few of the most useful ones: 24 | 25 | - `npm run init`: set up opam local switch and download OCaml, Melange and 26 | JavaScript dependencies 27 | - `npm run install-opam-npm`: install OCaml, Melange, and JavaScript 28 | dependencies 29 | - `npm run watch`: watch the filesystem and have Melange rebuild on every 30 | change 31 | - `npm run serve`: serve the application with a local HTTP server 32 | 33 | ## Book 34 | 35 | The files for the book associated with this repo are in the `docs` directory. 36 | See `docs/README.md` for details. 37 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | .vitepress/dist 2 | .vitepress/cache 3 | -------------------------------------------------------------------------------- /docs/.vitepress/config.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | import { defineConfig } from 'vitepress' 3 | import markdownItFootnote from 'markdown-it-footnote' 4 | 5 | // https://vitepress.dev/reference/site-config 6 | export default defineConfig({ 7 | title: 'Melange for React Devs', 8 | description: 'A project-based, guided introduction to Melange and its ecosystem for React developers', 9 | base: '/', 10 | lastUpdated: true, 11 | themeConfig: { 12 | search: { 13 | provider: 'local' 14 | }, 15 | editLink: { 16 | pattern: 'https://github.com/melange-re/melange-for-react-devs/edit/main/docs/:path' 17 | }, 18 | nav: [ 19 | { text: 'Home', link: '/' }, 20 | { text: 'Melange', link: 'https://melange.re' }, 21 | { text: 'ReasonReact', link: 'https://reasonml.github.io/reason-react/' } 22 | ], 23 | sidebar: [ 24 | { 25 | text: 'Chapters', 26 | items: [ 27 | { text: 'Intro', link: '/intro/' }, 28 | { text: 'Installation', link: '/installation/' }, 29 | { text: 'Counter', link: '/counter/' }, 30 | { text: 'Numeric Types', link: '/numeric-types/' }, 31 | { text: 'Celsius Converter', link: '/celsius-converter-exception/' }, 32 | { text: 'Celsius Converter Using Option', link: '/celsius-converter-option/' }, 33 | { text: 'Introduction to Dune', link: '/intro-to-dune/' }, 34 | { text: 'Order Confirmation', link: '/order-confirmation/' }, 35 | { text: 'Styling with CSS', link: '/styling-with-css/' }, 36 | { text: 'Better Sandwiches', link: '/better-sandwiches/' }, 37 | { text: 'Better Burgers', link: '/better-burgers/' }, 38 | { text: 'Sandwich Tests', link: '/sandwich-tests/' }, 39 | { text: 'Cram Tests', link: '/cram-tests/' }, 40 | { text: 'Burger Discounts', link: '/burger-discounts/' }, 41 | { text: 'Discounts Using Lists', link: '/discounts-lists/' }, 42 | { text: 'Promo Codes', link: '/promo-codes/' }, 43 | { text: 'Promo Component', link: '/promo-component/' }, 44 | { text: 'Order with Promo', link: '/order-with-promo/' }, 45 | ] 46 | } 47 | ], 48 | socialLinks: [ 49 | { icon: 'github', link: 'https://github.com/melange-re/melange-for-react-devs' } 50 | ], 51 | }, 52 | markdown: { 53 | linkify: false, 54 | config: (md) => { 55 | md.set({ typographer: true }) 56 | md.use(markdownItFootnote) 57 | }, 58 | languages: [ 59 | // Source: https://github.com/ocamllabs/vscode-ocaml-platform/blob/master/syntaxes/reason.json 60 | { 61 | id: 'reason', 62 | scopeName: 'source.reason', 63 | displayName: 'Reason', 64 | grammar: JSON.parse(readFileSync('./syntaxes/reason.json')), 65 | aliases: ['re', 'rei'], 66 | }, 67 | // Source: https://github.com/ocamllabs/vscode-ocaml-platform/blob/master/syntaxes/dune.json 68 | // with a few additions to support melange.emit 69 | { 70 | id: 'dune', 71 | scopeName: 'source.dune', 72 | displayName: 'Dune', 73 | grammar: JSON.parse(readFileSync('./syntaxes/dune.json')), 74 | }, 75 | // Source: https://github.com/ocamllabs/vscode-ocaml-platform/blob/master/syntaxes/cram.json 76 | { 77 | id: 'cram', 78 | scopeName: 'source.cram', 79 | displayName: 'Cram Test', 80 | grammar: JSON.parse(readFileSync('./syntaxes/cram.json')), 81 | aliases: ['t'], 82 | }, 83 | ], 84 | }, 85 | }) 86 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | npm install 3 | 4 | serve: 5 | npm run docs:dev 6 | 7 | build: 8 | npm run docs:build 9 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Melange for React Developers Book 2 | 3 | ## Installation 4 | 5 | ```bash 6 | make install 7 | ``` 8 | 9 | ## Commands 10 | 11 | Serve the site at http://localhost:5173/ 13 | 14 | ```bash 15 | make serve 16 | ``` 17 | 18 | ## Syntax files 19 | 20 | Syntax highlighting files were copied from 21 | https://github.com/ocamllabs/vscode-ocaml-platform/blob/master/syntaxes 22 | -------------------------------------------------------------------------------- /docs/better-burgers/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/better-sandwiches/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/burger-discounts/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | 10 | let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { 11 | let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; 12 | 13 | 15. // base cost 14 | +. toppingCost(onions, 0.2) 15 | +. toppingCost(cheese, 0.1) 16 | +. (tomatoes ? 0.05 : 0.0) 17 | +. toppingCost(bacon, 0.5); 18 | }; 19 | }; 20 | 21 | module Sandwich = { 22 | type t = 23 | | Portabello 24 | | Ham 25 | | Unicorn 26 | | Turducken; 27 | 28 | let toPrice = (~date: Js.Date.t, t) => { 29 | let day = date |> Js.Date.getDay |> int_of_float; 30 | 31 | switch (t) { 32 | | Portabello 33 | | Ham => 10. 34 | | Unicorn => 80. 35 | | Turducken when day == 2 => 10. 36 | | Turducken => 20. 37 | }; 38 | }; 39 | }; 40 | 41 | type t = 42 | | Sandwich(Sandwich.t) 43 | | Burger(Burger.t) 44 | | Hotdog; 45 | 46 | let toPrice = t => { 47 | switch (t) { 48 | | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date=Js.Date.make()) 49 | | Burger(burger) => Burger.toPrice(burger) 50 | | Hotdog => 5. 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /docs/burger-discounts/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/celsius-converter-exception/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/celsius-converter-option/Snippets.re: -------------------------------------------------------------------------------- 1 | let _ = { 2 | let celsius = ""; 3 | let convert = x => x; 4 | // #region float-of-string-opt 5 | ( 6 | switch (celsius |> float_of_string_opt) { 7 | | None => "error" 8 | | Some(fahrenheit) => 9 | (fahrenheit |> convert |> Js.Float.toFixed(~digits=2)) ++ {js|°F|js} 10 | } 11 | ) 12 | // #endregion float-of-string-opt 13 | |> ignore; 14 | }; 15 | 16 | let _ = { 17 | let celsius = ""; 18 | let convert = x => x; 19 | // #region option-map 20 | ( 21 | switch ( 22 | celsius 23 | |> float_of_string_opt 24 | |> Option.map(convert) 25 | |> Option.map(Js.Float.toFixed(~digits=2)) 26 | ) { 27 | | None => "error" 28 | | Some(fahrenheit) => fahrenheit ++ {js|°F|js} 29 | } 30 | ) 31 | // #endregion option-map 32 | |> ignore; 33 | }; 34 | 35 | let _ = { 36 | let celsius = ""; 37 | let convert = x => x; 38 | // #region inner-ternary 39 | ( 40 | switch (celsius |> float_of_string_opt |> Option.map(convert)) { 41 | | None => "error" 42 | | Some(fahrenheit) => 43 | fahrenheit > 212.0 44 | ? {js|Unreasonably hot🥵|js} 45 | : Js.Float.toFixed(fahrenheit, ~digits=2) ++ {js| °F|js} 46 | } 47 | ) 48 | // #endregion inner-ternary 49 | |> ignore; 50 | }; 51 | 52 | let _ = { 53 | let celsius = ""; 54 | let convert = x => x; 55 | // #region when-guard 56 | ( 57 | switch (celsius |> float_of_string_opt |> Option.map(convert)) { 58 | | None => "error" 59 | | Some(fahrenheit) when fahrenheit > 212.0 => {js|Unreasonably hot🥵|js} 60 | | Some(fahrenheit) => 61 | Js.Float.toFixed(fahrenheit, ~digits=2) ++ {js| °F|js} 62 | } 63 | ) 64 | // #endregion when-guard 65 | |> ignore; 66 | }; 67 | 68 | let _ = { 69 | let celsius = ""; 70 | let convert = x => x; 71 | // #region string-trim 72 | ( 73 | String.trim(celsius) == "" 74 | ? {js|?°F|js} 75 | : ( 76 | switch (celsius |> float_of_string_opt |> Option.map(convert)) { 77 | | None => "error" 78 | | Some(fahrenheit) when fahrenheit > 212.0 => {js|Unreasonably hot🥵|js} 79 | | Some(fahrenheit) => 80 | Js.Float.toFixed(fahrenheit, ~digits=2) ++ {js| °F|js} 81 | } 82 | ) 83 | ) 84 | // #endregion string-trim 85 | |> ignore; 86 | }; 87 | 88 | let _ = { 89 | let celsius = ""; 90 | let convert = x => x; 91 | // #region when-guard-cold 92 | ( 93 | switch (celsius |> float_of_string_opt |> Option.map(convert)) { 94 | | None => "error" 95 | | Some(fahrenheit) when fahrenheit < (-128.6) => {js|Unreasonably cold🥶|js} 96 | | Some(fahrenheit) when fahrenheit > 212.0 => {js|Unreasonably hot🥵|js} 97 | | Some(fahrenheit) => 98 | Js.Float.toFixed(fahrenheit, ~digits=2) ++ {js|°F|js} 99 | } 100 | ) 101 | // #endregion when-guard-cold 102 | |> ignore; 103 | }; 104 | 105 | // #region float-from-string 106 | let floatFromString = text => { 107 | let value = Js.Float.fromString(text); 108 | Js.Float.isNaN(value) ? None : Some(value); 109 | }; 110 | // #endregion float-from-string 111 | 112 | let _ = { 113 | let celsius = ""; 114 | let convert = x => x; 115 | // #region switch-float-from-string 116 | ( 117 | switch (celsius |> floatFromString |> Option.map(convert)) { 118 | | None => "error" 119 | | Some(fahrenheit) when fahrenheit < (-128.6) => {js|Unreasonably cold🥶|js} 120 | | Some(fahrenheit) when fahrenheit > 212.0 => {js|Unreasonably hot🥵|js} 121 | | Some(fahrenheit) => 122 | Js.Float.toFixed(fahrenheit, ~digits=2) ++ {js|°F|js} 123 | } 124 | ) 125 | // #endregion switch-float-from-string 126 | |> ignore; 127 | }; 128 | -------------------------------------------------------------------------------- /docs/celsius-converter-option/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/counter/Snippets.re: -------------------------------------------------------------------------------- 1 | // #region app-v1 2 | module App = { 3 | [@react.component] 4 | let make = () =>
{React.string("welcome to my app")}
; 5 | }; 6 | // #endregion app-v1 7 | 8 | let _ = { 9 | // #region use-app-component 10 | let node = ReactDOM.querySelector("#root"); 11 | switch (node) { 12 | | None => 13 | Js.Console.error("Failed to start React: couldn't find the #root element") 14 | | Some(root) => 15 | let root = ReactDOM.Client.createRoot(root); 16 | ReactDOM.Client.render(root, ); 17 | }; 18 | // #endregion use-app-component 19 | }; 20 | 21 | module Counter = { 22 | // #region counter-v1 23 | [@react.component] 24 | let make = () => { 25 | let (counter, setCounter) = React.useState(() => 0); 26 | 27 |
28 | 31 | {React.string(Int.to_string(counter))} 32 | 35 |
; 36 | }; 37 | // #endregion counter-v1 38 | }; 39 | 40 | module AppV2 = { 41 | // #region app-v2 42 | module App = { 43 | [@react.component] 44 | let make = () => ; 45 | }; 46 | // #endregion app-v2 47 | }; 48 | 49 | let _ = { 50 | let (counter, setCounter) = React.useState(() => 0); 51 | 52 | // #region render-with-styling 53 |
60 | 63 | {counter |> Int.to_string |> React.string} 64 | 67 |
; 68 | // #endregion render-with-styling 69 | }; 70 | -------------------------------------------------------------------------------- /docs/counter/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/cram-tests/BurgerTests.re: -------------------------------------------------------------------------------- 1 | // #region new-file 2 | open Fest; 3 | 4 | test("A fully-loaded burger", () => 5 | expect 6 | |> equal( 7 | Item.Burger.toEmoji({ 8 | lettuce: true, 9 | onions: 2, 10 | cheese: 3, 11 | tomatoes: true, 12 | bacon: 4, 13 | }), 14 | {js|🍔|js}, 15 | ) 16 | ); 17 | // #endregion new-file 18 | 19 | // #region first-test-fixed 20 | test("A fully-loaded burger", () => 21 | expect 22 | |> equal( 23 | Item.Burger.toEmoji({ 24 | lettuce: true, 25 | onions: 2, 26 | cheese: 3, 27 | tomatoes: true, 28 | bacon: 4, 29 | }), 30 | {js|🍔{🥬,🍅,🧅×2,🧀×3,🥓×4}|js}, 31 | ) 32 | ); 33 | // #endregion first-test-fixed 34 | 35 | // #region three-tests-fixed 36 | test("Burger with 0 of onions, cheese, or bacon doesn't show those emoji", () => 37 | expect 38 | |> equal( 39 | Item.Burger.toEmoji({ 40 | lettuce: true, 41 | tomatoes: true, 42 | onions: 0, 43 | cheese: 0, 44 | bacon: 0, 45 | }), 46 | {js|🍔{🥬,🍅}|js}, 47 | ) 48 | ); 49 | 50 | test( 51 | "Burger with 1 of onions, cheese, or bacon should show just the emoji without ×", 52 | () => 53 | expect 54 | |> equal( 55 | Item.Burger.toEmoji({ 56 | lettuce: true, 57 | tomatoes: true, 58 | onions: 1, 59 | cheese: 1, 60 | bacon: 1, 61 | }), 62 | {js|🍔{🥬,🍅,🧅,🧀,🥓}|js}, 63 | ) 64 | ); 65 | 66 | test("Burger with 2 or more of onions, cheese, or bacon should show ×", () => 67 | expect 68 | |> equal( 69 | Item.Burger.toEmoji({ 70 | lettuce: true, 71 | tomatoes: true, 72 | onions: 2, 73 | cheese: 2, 74 | bacon: 2, 75 | }), 76 | {js|🍔{🥬,🍅,🧅×2,🧀×2,🥓×2}|js}, 77 | ) 78 | ); 79 | // #endregion three-tests-fixed 80 | 81 | // #region bowl-test 82 | test("Burger with more than 12 toppings should also show bowl emoji", () => { 83 | expect 84 | |> equal( 85 | Item.Burger.toEmoji({ 86 | lettuce: true, 87 | tomatoes: true, 88 | onions: 4, 89 | cheese: 2, 90 | bacon: 5, 91 | }), 92 | {js|🍔🥣{🥬,🍅,🧅×4,🧀×2,🥓×5}|js}, 93 | ); 94 | 95 | expect 96 | |> equal( 97 | Item.Burger.toEmoji({ 98 | lettuce: true, 99 | tomatoes: true, 100 | onions: 4, 101 | cheese: 2, 102 | bacon: 4, 103 | }), 104 | {js|🍔{🥬,🍅,🧅×4,🧀×2,🥓×4}|js}, 105 | ); 106 | }); 107 | // #endregion bowl-test 108 | -------------------------------------------------------------------------------- /docs/cram-tests/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | 10 | // #region to-emoji 11 | let toEmoji = t => { 12 | let multiple = (emoji, count) => 13 | switch (count) { 14 | | 0 => "" 15 | | 1 => emoji 16 | | count => Printf.sprintf({js|%s×%d|js}, emoji, count) 17 | }; 18 | 19 | switch (t) { 20 | | {lettuce: false, onions: 0, cheese: 0, tomatoes: false, bacon: 0} => {js|🍔|js} 21 | | {lettuce, onions, cheese, tomatoes, bacon} => 22 | let toppingsCount = 23 | (lettuce ? 1 : 0) + (tomatoes ? 1 : 0) + onions + cheese + bacon; 24 | 25 | Printf.sprintf( 26 | {js|🍔%s{%s}|js}, 27 | toppingsCount > 12 ? {js|🥣|js} : "", 28 | [| 29 | lettuce ? {js|🥬|js} : "", 30 | tomatoes ? {js|🍅|js} : "", 31 | multiple({js|🧅|js}, onions), 32 | multiple({js|🧀|js}, cheese), 33 | multiple({js|🥓|js}, bacon), 34 | |] 35 | |> Js.Array.filter(~f=str => str != "") 36 | |> Js.Array.join(~sep=","), 37 | ); 38 | }; 39 | // #endregion to-emoji 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /docs/cram-tests/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/cram-tests/duration-diff: -------------------------------------------------------------------------------- 1 | @@ -4,17 +4,17 @@ Sandwich tests 2 | # Subtest: Item.Sandwich.toEmoji 3 | ok 1 - Item.Sandwich.toEmoji 4 | --- 5 | - duration_ms: 2.225448 6 | + duration_ms: 3.012631 7 | ... 8 | # Subtest: Item.Sandwich.toPrice 9 | ok 2 - Item.Sandwich.toPrice 10 | --- 11 | - duration_ms: 0.262691 12 | + duration_ms: 0.276333 13 | ... 14 | # Subtest: Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 15 | ok 3 - Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 16 | --- 17 | - duration_ms: 0.109477 18 | + duration_ms: 0.140118 19 | ... 20 | 1..3 21 | # tests 3 22 | @@ -24,4 +24,4 @@ Sandwich tests 23 | # cancelled 0 24 | # skipped 0 25 | # todo 0 26 | - # duration_ms 0.125331 27 | + # duration_ms 0.143172 28 | -------------------------------------------------------------------------------- /docs/cram-tests/initial-diff: -------------------------------------------------------------------------------- 1 | @@ -1,2 +1,27 @@ 2 | Sandwich tests 3 | $ node ./output/src/cram-tests/SandwichTests.mjs 4 | + TAP version 13 5 | + # Subtest: Item.Sandwich.toEmoji 6 | + ok 1 - Item.Sandwich.toEmoji 7 | + --- 8 | + duration_ms: 2.173649 9 | + ... 10 | + # Subtest: Item.Sandwich.toPrice 11 | + ok 2 - Item.Sandwich.toPrice 12 | + --- 13 | + duration_ms: 0.216146 14 | + ... 15 | + # Subtest: Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 16 | + ok 3 - Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 17 | + --- 18 | + duration_ms: 0.106589 19 | + ... 20 | + 1..3 21 | + # tests 3 22 | + # suites 0 23 | + # pass 3 24 | + # fail 0 25 | + # cancelled 0 26 | + # skipped 0 27 | + # todo 0 28 | + # duration_ms 0.111087 29 | -------------------------------------------------------------------------------- /docs/cram-tests/tests: -------------------------------------------------------------------------------- 1 | # region first-cram 2 | Sandwich tests 3 | $ node ./output/src/order-confirmation/SandwichTests.mjs 4 | TAP version 13 5 | # Subtest: Item.Sandwich.toEmoji 6 | ok 1 - Item.Sandwich.toEmoji 7 | --- 8 | duration_ms: 1.821041 9 | ... 10 | # Subtest: Item.Sandwich.toPrice 11 | ok 2 - Item.Sandwich.toPrice 12 | --- 13 | duration_ms: 0.731916 14 | ... 15 | # Subtest: Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 16 | ok 3 - Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 17 | --- 18 | duration_ms: 0.086875 19 | ... 20 | 1..3 21 | # tests 3 22 | # suites 0 23 | # pass 3 24 | # fail 0 25 | # cancelled 0 26 | # skipped 0 27 | # todo 0 28 | # duration_ms 0.081042 29 | # endregion first-cram 30 | 31 | # region first-burger-cram 32 | Burger tests 33 | $ node ./output/src/order-confirmation/BurgerTests.mjs | sed '/duration_ms/d' 34 | TAP version 13 35 | # Subtest: A fully-loaded burger 36 | ok 1 - A fully-loaded burger 37 | --- 38 | ... 39 | 1..1 40 | # tests 1 41 | # suites 0 42 | # pass 1 43 | # fail 0 44 | # cancelled 0 45 | # skipped 0 46 | # todo 0 47 | # endregion first-burger-cram 48 | 49 | -------------------------------------------------------------------------------- /docs/discounts-lists/Discount.re: -------------------------------------------------------------------------------- 1 | // #region get-free-burger 2 | /** Buy 2 burgers, get 1 free */ 3 | let getFreeBurger = (items: list(Item.t)) => { 4 | let prices = 5 | items 6 | |> List.filter(item => 7 | switch (item) { 8 | | Item.Burger(_) => true 9 | | Sandwich(_) 10 | | Hotdog => false 11 | } 12 | ) 13 | |> List.map(Item.toPrice) 14 | |> List.sort((x, y) => - compare(x, y)); 15 | 16 | switch (prices) { 17 | | [] 18 | | [_] => None 19 | | [_, cheaperPrice, ..._] => Some(cheaperPrice) 20 | }; 21 | }; 22 | // #endregion get-free-burger 23 | 24 | ignore(getFreeBurger); 25 | 26 | // #region get-half-off 27 | /** Buy 1+ burger with 1+ of every topping, get half off */ 28 | let getHalfOff = (items: list(Item.t)) => { 29 | let meetsCondition = 30 | items 31 | |> List.exists( 32 | fun 33 | | Item.Burger({lettuce: true, tomatoes: true, onions, cheese, bacon}) 34 | when onions > 0 && cheese > 0 && bacon > 0 => 35 | true 36 | | Burger(_) 37 | | Sandwich(_) 38 | | Hotdog => false, 39 | ); 40 | 41 | switch (meetsCondition) { 42 | | false => None 43 | | true => 44 | let total = 45 | items 46 | |> List.fold_left((total, item) => total +. Item.toPrice(item), 0.0); 47 | Some(total /. 2.0); 48 | }; 49 | }; 50 | // #endregion get-half-off 51 | 52 | // #region get-free-burger-improved 53 | /** Buy 2 burgers, get 1 free */ 54 | let getFreeBurger = (items: list(Item.t)) => { 55 | let prices = 56 | items 57 | |> List.filter_map(item => 58 | switch (item) { 59 | | Item.Burger(burger) => Some(Item.Burger.toPrice(burger)) 60 | | Sandwich(_) 61 | | Hotdog => None 62 | } 63 | ) 64 | |> List.sort((x, y) => - Float.compare(x, y)); 65 | 66 | switch (prices) { 67 | | [] 68 | | [_] => None 69 | | [_, cheaperPrice, ..._] => Some(cheaperPrice) 70 | }; 71 | }; 72 | // #endregion get-free-burger-improved 73 | 74 | ignore(getFreeBurger); 75 | 76 | // #region get-free-burger-nth 77 | let getFreeBurger = (items: list(Item.t)) => { 78 | items 79 | |> List.filter(item => 80 | switch (item) { 81 | | Item.Burger(_) => true 82 | | Sandwich(_) 83 | | Hotdog => false 84 | } 85 | ) 86 | |> List.map(Item.toPrice) 87 | |> List.sort((x, y) => - Float.compare(x, y)) 88 | |> ListSafe.nth(1); 89 | }; 90 | // #endregion get-free-burger-nth 91 | 92 | // #region get-free-burgers 93 | /** Buy n burgers, get n/2 burgers free */ 94 | let getFreeBurgers = (items: list(Item.t)) => { 95 | let prices = 96 | items 97 | |> List.filter_map(item => 98 | switch (item) { 99 | | Item.Burger(burger) => Some(Item.Burger.toPrice(burger)) 100 | | Sandwich(_) 101 | | Hotdog => None 102 | } 103 | ); 104 | 105 | switch (prices) { 106 | | [] 107 | | [_] => None 108 | | prices => 109 | let result = 110 | prices 111 | |> List.sort((x, y) => - Float.compare(x, y)) 112 | |> List.filteri((index, _) => index mod 2 == 1) 113 | |> List.fold_left((+.), 0.0); 114 | Some(result); 115 | }; 116 | }; 117 | // #endregion get-free-burgers 118 | -------------------------------------------------------------------------------- /docs/discounts-lists/DiscountTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | // #region first-test 4 | test("0 burgers, no discount", () => 5 | expect 6 | |> equal( 7 | Discount.getFreeBurger([Hotdog, Sandwich(Ham), Sandwich(Turducken)]), 8 | None, 9 | ) 10 | ); 11 | // #endregion first-test 12 | 13 | let burger: Item.Burger.t = { 14 | lettuce: false, 15 | onions: 0, 16 | cheese: 0, 17 | tomatoes: false, 18 | bacon: 0, 19 | }; 20 | 21 | // #region get-free-burgers-test 22 | test("7 burgers, return Some(46.75)", () => 23 | expect 24 | |> equal( 25 | Discount.getFreeBurgers([ 26 | Burger(burger), // 15 27 | Hotdog, 28 | Burger({ 29 | ...burger, 30 | cheese: 5, 31 | }), // 15.50 32 | Sandwich(Unicorn), 33 | Burger({ 34 | ...burger, 35 | bacon: 4, 36 | }), // 17.00 37 | Burger({ 38 | ...burger, 39 | tomatoes: true, 40 | cheese: 1, 41 | }), // 15.15 42 | Sandwich(Ham), 43 | Burger({ 44 | ...burger, 45 | bacon: 2, 46 | }), // 16.00 47 | Burger({ 48 | ...burger, 49 | onions: 6, 50 | }), // 16.20 51 | Sandwich(Portabello), 52 | Burger({ 53 | ...burger, 54 | tomatoes: true, 55 | }) // 15.05 56 | ]), 57 | Some(46.75), 58 | ) 59 | ); 60 | // #endregion get-free-burgers-test 61 | -------------------------------------------------------------------------------- /docs/discounts-lists/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | 10 | let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { 11 | let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; 12 | 13 | 15. // base cost 14 | +. toppingCost(onions, 0.2) 15 | +. toppingCost(cheese, 0.1) 16 | +. (tomatoes ? 0.05 : 0.0) 17 | +. toppingCost(bacon, 0.5); 18 | }; 19 | }; 20 | 21 | module Sandwich = { 22 | type t = 23 | | Portabello 24 | | Ham 25 | | Unicorn 26 | | Turducken; 27 | 28 | let toPrice = (~date: Js.Date.t, t) => { 29 | let day = date |> Js.Date.getDay |> int_of_float; 30 | 31 | switch (t) { 32 | | Portabello 33 | | Ham => 10. 34 | | Unicorn => 80. 35 | | Turducken when day == 2 => 10. 36 | | Turducken => 20. 37 | }; 38 | }; 39 | }; 40 | 41 | type t = 42 | | Sandwich(Sandwich.t) 43 | | Burger(Burger.t) 44 | | Hotdog; 45 | 46 | let toPrice = t => { 47 | switch (t) { 48 | | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date=Js.Date.make()) 49 | | Burger(burger) => Burger.toPrice(burger) 50 | | Hotdog => 5. 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /docs/discounts-lists/ListSafe.re: -------------------------------------------------------------------------------- 1 | /** Return the nth element encased in Some; if it doesn't exist, return None */ 2 | let nth = (n, list) => n < 0 ? None : List.nth_opt(list, n); 3 | -------------------------------------------------------------------------------- /docs/discounts-lists/Order.re: -------------------------------------------------------------------------------- 1 | type t = list(Item.t); 2 | 3 | module Format = { 4 | let currency = _ => React.null; 5 | }; 6 | 7 | module OrderItem = { 8 | [@react.component] 9 | let make = (~item as _: Item.t) =>
; 10 | }; 11 | 12 | let css = { 13 | "order": "", 14 | "total": "", 15 | }; 16 | 17 | // #region make 18 | [@react.component] 19 | let make = (~items: t) => { 20 | let total = 21 | items 22 | |> ListLabels.fold_left(~init=0., ~f=(acc, order) => 23 | acc +. Item.toPrice(order) 24 | ); 25 | 26 | 27 | 28 | {items 29 | |> List.mapi((index, item) => 30 | 31 | ) 32 | |> Stdlib.Array.of_list 33 | |> React.array} 34 | 35 | 36 | 37 | 38 | 39 |
{React.string("Total")} {total |> Format.currency}
; 40 | }; 41 | // #endregion make 42 | -------------------------------------------------------------------------------- /docs/discounts-lists/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/discounts-lists/function-popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melange-re/melange-for-react-devs/3af15519e0bcbdf6c293d92092e0e169c22985b8/docs/discounts-lists/function-popup.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "Melange for React Devs" 7 | text: "A project-based, guided introduction to Melange, for React developers" 8 | actions: 9 | - theme: brand 10 | text: Get started 11 | link: /intro/ 12 | - theme: alt 13 | text: View on GitHub 14 | link: https://github.com/melange-re/melange-for-react-devs 15 | 16 | --- 17 | -------------------------------------------------------------------------------- /docs/numeric-types/Snippets.re: -------------------------------------------------------------------------------- 1 | // #region counter 2 | module Counter = { 3 | [@react.component] 4 | let make = () => { 5 | let (counter, setCounter) = React.useState(() => 0); 6 | 7 |
14 | 17 | {counter |> Int.to_string |> React.string} 18 | 21 |
; 22 | }; 23 | }; 24 | 25 | switch (ReactDOM.querySelector("#preview")) { 26 | | None => Js.log("Failed to start React: couldn't find the #preview element") 27 | | Some(root) => 28 | let root = ReactDOM.Client.createRoot(root); 29 | ReactDOM.Client.render(root, ); 30 | }; 31 | // #endregion counter 32 | 33 | // #region styles 34 | module Styles = { 35 | // Alias the function to save on keystrokes 36 | let make = ReactDOM.Style.make; 37 | 38 | let root = 39 | make( 40 | ~fontSize="2em", 41 | ~padding="1em", 42 | ~display="flex", 43 | ~gridGap="1em", 44 | ~alignItems="center", 45 | (), 46 | ); 47 | 48 | let button = 49 | make( 50 | ~fontSize="1em", 51 | ~border="1px solid white", 52 | ~borderRadius="0.5em", 53 | ~padding="0.5em", 54 | (), 55 | ); 56 | 57 | let number = make(~minWidth="2em", ~textAlign="center", ()); 58 | }; 59 | // #endregion styles 60 | 61 | let _ = { 62 | let setCounter = _ => (); 63 | let counter = 0; 64 | // #region render-with-styles 65 |
66 | 69 | 70 | {counter |> Int.to_string |> React.string} 71 | 72 | 75 |
; 76 | // #endregion render-with-styles 77 | }; 78 | -------------------------------------------------------------------------------- /docs/numeric-types/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/order-confirmation/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | let items: Order.t = [|Sandwich, Burger, Sandwich|]; 3 | 4 | [@react.component] 5 | let make = () => 6 |
7 |

{React.string("Order confirmation")}

8 | 9 |
; 10 | }; 11 | 12 | let node = ReactDOM.querySelector("#root"); 13 | switch (node) { 14 | | None => 15 | Js.Console.error("Failed to start React: couldn't find the #root element") 16 | | Some(root) => 17 | let root = ReactDOM.Client.createRoot(root); 18 | ReactDOM.Client.render(root, ); 19 | }; 20 | -------------------------------------------------------------------------------- /docs/order-confirmation/Item.re: -------------------------------------------------------------------------------- 1 | // #region type-t 2 | type t = 3 | | Sandwich 4 | | Burger; 5 | // #endregion type-t 6 | 7 | let _ = { 8 | // #region to-price 9 | let toPrice = t => 10 | switch (t) { 11 | | Sandwich => 10. 12 | | Burger => 15. 13 | }; 14 | // #endregion to-price 15 | toPrice(Sandwich) |> ignore; 16 | }; 17 | 18 | // #region to-price-fun 19 | let toPrice = 20 | fun 21 | | Sandwich => 10. 22 | | Burger => 15.; 23 | // #endregion to-price-fun 24 | 25 | // #region to-emoji 26 | let toEmoji = 27 | fun 28 | | Sandwich => {js|🥪|js} 29 | | Burger => {js|🍔|js}; 30 | // #endregion to-emoji 31 | 32 | // #region make 33 | [@react.component] 34 | let make = (~item: t) => 35 | 36 | {item |> toEmoji |> React.string} 37 | 38 | {item |> toPrice |> Js.Float.toFixed(~digits=2) |> React.string} 39 | 40 | ; 41 | // #endregion make 42 | 43 | module AddHotdog = { 44 | // #region hotdog 45 | type t = 46 | | Sandwich 47 | | Burger 48 | | Hotdog; 49 | 50 | let toPrice = 51 | fun 52 | | Sandwich => 10. 53 | | Burger => 15. 54 | | Hotdog => 5.; 55 | 56 | let toEmoji = 57 | fun 58 | | Sandwich => {js|🥪|js} 59 | | Burger => {js|🍔|js} 60 | | Hotdog => {js|🌭|js}; 61 | // #endregion hotdog 62 | }; 63 | -------------------------------------------------------------------------------- /docs/order-confirmation/Order.re: -------------------------------------------------------------------------------- 1 | // #region order 2 | type t = array(Item.t); 3 | 4 | [@react.component] 5 | let make = (~items: t) => { 6 | let total = 7 | items 8 | |> Js.Array.reduce(~init=0., ~f=(acc, order) => 9 | acc +. Item.toPrice(order) 10 | ); 11 | 12 | 13 | 14 | {items |> Js.Array.map(~f=item => ) |> React.array} 15 | 16 | 17 | 18 | 19 | 20 |
{React.string("Total")} {total |> Js.Float.toFixed(~digits=2) |> React.string}
; 21 | }; 22 | // #endregion order 23 | 24 | let _ = { 25 | let items = [||]; 26 | // #region mapi 27 | items 28 | |> Js.Array.mapi(~f=(item, index) => 29 | 30 | ) 31 | |> React.array 32 | // #endregion mapi 33 | |> ignore; 34 | }; 35 | 36 | let _ = { 37 | let items = [||]; 38 | // #region order-make-item-rows 39 | let total = 40 | items 41 | |> Js.Array.reduce(~init=0., ~f=(acc, order) => 42 | acc +. Item.toPrice(order) 43 | ); 44 | 45 | let itemRows: array(React.element) = 46 | items |> Js.Array.map(~f=item => ); 47 | 48 | 49 | 50 | {itemRows |> React.array} 51 | 52 | 53 | 54 | 55 | 56 |
{React.string("Total")} {total |> Js.Float.toFixed(~digits=2) |> React.string}
; 57 | // #endregion order-make-item-rows 58 | }; 59 | -------------------------------------------------------------------------------- /docs/order-confirmation/Snippets.re: -------------------------------------------------------------------------------- 1 | let _ = { 2 | // #region react-array-demo 3 | let elemArray: array(React.element) = 4 | [|"a", "b", "c"|] |> Js.Array.map(~f=x => React.string(x)); 5 | Js.log(elemArray); 6 | Js.log(React.array(elemArray)); 7 | // #endregion react-array-demo 8 | }; 9 | -------------------------------------------------------------------------------- /docs/order-confirmation/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/order-with-promo/DateInput.re: -------------------------------------------------------------------------------- 1 | let stringToDate = s => 2 | // add "T00:00" to make sure the date is in local time 3 | s ++ "T00:00" |> Js.Date.fromString; 4 | 5 | let dateToString = d => 6 | Printf.sprintf( 7 | "%4.0f-%02.0f-%02.0f", 8 | Js.Date.getFullYear(d), 9 | Js.Date.getMonth(d) +. 1., 10 | Js.Date.getDate(d), 11 | ); 12 | 13 | [@react.component] 14 | let make = (~date: Js.Date.t, ~onChange: Js.Date.t => unit) => { 15 | evt |> RR.getValueFromEvent |> stringToDate |> onChange} 20 | />; 21 | }; 22 | -------------------------------------------------------------------------------- /docs/order-with-promo/Discount.re: -------------------------------------------------------------------------------- 1 | type error = 2 | | InvalidCode 3 | | ExpiredCode; 4 | 5 | let getDiscountFunction = (code, _date) => { 6 | switch (code) { 7 | | "FREE" => Ok(_items => Ok(0.0)) 8 | | _ => Error(InvalidCode) 9 | }; 10 | }; 11 | 12 | let getSandwichHalfOff = (~date as _: Js.Date.t, _items: list(Item.t)) => 13 | Error(`MissingSandwichTypes([])); 14 | 15 | type sandwichTracker = { 16 | portabello: bool, 17 | ham: bool, 18 | unicorn: bool, 19 | turducken: bool, 20 | }; 21 | 22 | let _ = 23 | (~tracker, ~date, ~items) => { 24 | let _ = 25 | // #region missing-sandwich-types 26 | switch (tracker) { 27 | | {portabello: true, ham: true, unicorn: true, turducken: true} => 28 | let total = 29 | items 30 | |> ListLabels.fold_left(~init=0.0, ~f=(total, item) => 31 | total +. Item.toPrice(item, ~date) 32 | ); 33 | Ok(total /. 2.0); 34 | | tracker => 35 | let missing = 36 | [ 37 | tracker.portabello ? "" : "portabello", 38 | tracker.ham ? "" : "ham", 39 | tracker.unicorn ? "" : "unicorn", 40 | tracker.turducken ? "" : "turducken", 41 | ] 42 | |> List.filter((!=)("")); 43 | Error(`MissingSandwichTypes(missing)); 44 | }; 45 | // #endregion missing-sandwich-types 46 | (); 47 | }; 48 | -------------------------------------------------------------------------------- /docs/order-with-promo/DiscountTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | let june3 = Js.Date.fromString("2024-06-03T00:00"); 4 | 5 | module SandwichHalfOff = { 6 | // #region not-all-sandwiches 7 | test("Not all sandwiches, return Error", () => 8 | expect 9 | |> deepEqual( 10 | Discount.getSandwichHalfOff( 11 | ~date=june3, 12 | [ 13 | Sandwich(Unicorn), 14 | Hotdog, 15 | Sandwich(Portabello), 16 | Sandwich(Ham), 17 | ], 18 | ), 19 | Error(`MissingSandwichTypes(["turducken"])), 20 | ) 21 | ); 22 | // #endregion not-all-sandwiches 23 | }; 24 | -------------------------------------------------------------------------------- /docs/order-with-promo/Index.re: -------------------------------------------------------------------------------- 1 | let node = ReactDOM.querySelector("#root"); 2 | switch (node) { 3 | | None => 4 | Js.Console.error("Failed to start React: couldn't find the #root element") 5 | | Some(root) => 6 | let root = ReactDOM.Client.createRoot(root); 7 | ReactDOM.Client.render(root, ); 8 | }; 9 | -------------------------------------------------------------------------------- /docs/order-with-promo/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | }; 10 | 11 | module Sandwich = { 12 | type t = 13 | | Portabello 14 | | Ham 15 | | Unicorn 16 | | Turducken; 17 | }; 18 | 19 | type t = 20 | | Sandwich(Sandwich.t) 21 | | Burger(Burger.t) 22 | | Hotdog; 23 | 24 | let toPrice = (~date as _: Js.Date.t, _t: t) => 0.; 25 | 26 | let toEmoji = (_t: t) => ""; 27 | -------------------------------------------------------------------------------- /docs/order-with-promo/Order.re: -------------------------------------------------------------------------------- 1 | type t = list(Item.t); 2 | 3 | module OrderItem = { 4 | [@react.component] 5 | let make = (~item as _: Item.t) =>
; 6 | }; 7 | 8 | module Style = { 9 | let order = ""; 10 | let total = ""; 11 | 12 | // #region promo-class 13 | let promo = [%cx 14 | {| 15 | border-top: 1px solid gray; 16 | text-align: right; 17 | vertical-align: top; 18 | |} 19 | ]; 20 | // #endregion promo-class 21 | }; 22 | 23 | // #region make 24 | [@react.component] 25 | let make = (~items: t, ~date: Js.Date.t) => { 26 | let (discount, setDiscount) = RR.useStateValue(0.0); 27 | 28 | let subtotal = 29 | items 30 | |> ListLabels.fold_left(~init=0., ~f=(acc, order) => 31 | acc +. Item.toPrice(order, ~date) 32 | ); 33 | 34 | 35 | 36 | {items 37 | |> List.mapi((index, item) => 38 | 39 | ) 40 | |> RR.list} 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
{RR.s("Subtotal")} {subtotal |> RR.currency}
{RR.s("Promo code")}
{RR.s("Total")} {subtotal -. discount |> RR.currency}
; 55 | }; 56 | // #endregion make 57 | 58 | let _ = 59 | (setDiscount, items, date) => { 60 | <> 61 | // #region set-promo-class 62 | 63 | {RR.s("Promo code")} 64 | 65 | 66 | // #endregion set-promo-class 67 | ; 68 | }; 69 | -------------------------------------------------------------------------------- /docs/order-with-promo/RR.re: -------------------------------------------------------------------------------- 1 | let getValueFromEvent = _evt => ""; 2 | 3 | let s = React.string; 4 | 5 | let useStateValue = initial => 6 | React.useReducer((_state, newState) => newState, initial); 7 | 8 | let list = list => list |> Stdlib.Array.of_list |> React.array; 9 | 10 | let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; 11 | 12 | // #region use-effect-1 13 | /** Helper for [React.useEffect1] */ 14 | let useEffect1 = (func, dep) => React.useEffect1(func, [|dep|]); 15 | // #endregion use-effect-1 16 | -------------------------------------------------------------------------------- /docs/order-with-promo/Types.re: -------------------------------------------------------------------------------- 1 | /** 2 | 3 | // #region inferred-type 4 | [> `CodeError(Discount.error) 5 | | `Discount(float) 6 | | `DiscountError([> `MissingSandwichTypes 7 | | `NeedMegaBurger 8 | | `NeedOneBurger 9 | | `NeedTwoBurgers ]) 10 | | `NoSubmittedCode ] 11 | // #endregion inferred-type 12 | 13 | */ 14 | 15 | /** 16 | 17 | // #region bad-discount-type 18 | type discount = 19 | | CodeError(Discount.error) 20 | | Discount(float) 21 | | DiscountError( 22 | [> 23 | | `MissingSandwichTypes 24 | | `NeedMegaBurger 25 | | `NeedOneBurger 26 | | `NeedTwoBurgers 27 | ], 28 | ) 29 | | NoSubmittedCode; 30 | // #endregion bad-discount-type 31 | 32 | */ 33 | 34 | // #region delete-refinement 35 | type discount = 36 | | CodeError(Discount.error) 37 | | Discount(float) 38 | | DiscountError( 39 | [ 40 | | `MissingSandwichTypes 41 | | `NeedMegaBurger 42 | | `NeedOneBurger 43 | | `NeedTwoBurgers 44 | ], 45 | ) 46 | | NoSubmittedCode; 47 | // #endregion delete-refinement 48 | 49 | module TypeVar = { 50 | // #region type-variable 51 | type discount('a) = 52 | | CodeError(Discount.error) 53 | | Discount(float) 54 | | DiscountError('a) 55 | | NoSubmittedCode; 56 | // #endregion type-variable 57 | }; 58 | 59 | /** 60 | 61 | // #region explicit-type-var 62 | type discount = 63 | | CodeError(Discount.error) 64 | | Discount(float) 65 | | DiscountError( 66 | [> 67 | | `MissingSandwichTypes 68 | | `NeedMegaBurger 69 | | `NeedOneBurger 70 | | `NeedTwoBurgers 71 | ] as 'a, 72 | ) 73 | | NoSubmittedCode; 74 | // #endregion explicit-type-var 75 | 76 | */ 77 | module AddTypeArg = { 78 | // #region add-type-arg 79 | type discount('a) = 80 | | CodeError(Discount.error) 81 | | Discount(float) 82 | | DiscountError( 83 | [> 84 | | `MissingSandwichTypes 85 | | `NeedMegaBurger 86 | | `NeedOneBurger 87 | | `NeedTwoBurgers 88 | ] as 'a, 89 | ) 90 | | NoSubmittedCode; 91 | // #endregion add-type-arg 92 | }; 93 | 94 | module MustBePoly = { 95 | // #region must-be-poly 96 | type discount('a) = 97 | | CodeError(Discount.error) 98 | | Discount(float) 99 | | DiscountError([> ] as 'a) 100 | | NoSubmittedCode; 101 | // #endregion must-be-poly 102 | }; 103 | -------------------------------------------------------------------------------- /docs/order-with-promo/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest styled-ppx.melange) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx styled-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/order-with-promo/type-error.txt: -------------------------------------------------------------------------------- 1 | Error: A type variable is unbound in this type declaration. 2 | In case 3 | DiscountError of ([> `MissingSandwichTypes 4 | | `NeedMegaBurger 5 | | `NeedOneBurger 6 | | `NeedTwoBurgers ] 7 | as 'a) the variable 'a is unbound 8 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "melange-for-react-devs-book", 3 | "type": "module", 4 | "devDependencies": { 5 | "markdown-it-footnote": "^3.0.3", 6 | "vitepress": "^1.0.0-rc.22" 7 | }, 8 | "scripts": { 9 | "docs:dev": "vitepress dev", 10 | "docs:build": "vitepress build", 11 | "docs:preview": "vitepress preview" 12 | } 13 | } -------------------------------------------------------------------------------- /docs/promo-codes/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | 10 | let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { 11 | let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; 12 | 13 | 15. // base cost 14 | +. toppingCost(onions, 0.2) 15 | +. toppingCost(cheese, 0.1) 16 | +. (tomatoes ? 0.05 : 0.0) 17 | +. toppingCost(bacon, 0.5); 18 | }; 19 | }; 20 | 21 | module Sandwich = { 22 | type t = 23 | | Portabello 24 | | Ham 25 | | Unicorn 26 | | Turducken; 27 | 28 | let toPrice = (~date: Js.Date.t, t) => { 29 | let day = date |> Js.Date.getDay |> int_of_float; 30 | 31 | switch (t) { 32 | | Portabello 33 | | Ham => 10. 34 | | Unicorn => 80. 35 | | Turducken when day == 2 => 10. 36 | | Turducken => 20. 37 | }; 38 | }; 39 | }; 40 | 41 | type t = 42 | | Sandwich(Sandwich.t) 43 | | Burger(Burger.t) 44 | | Hotdog; 45 | 46 | let toPrice = t => { 47 | switch (t) { 48 | | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date=Js.Date.make()) 49 | | Burger(burger) => Burger.toPrice(burger) 50 | | Hotdog => 5. 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /docs/promo-codes/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/promo-component/Discount.re: -------------------------------------------------------------------------------- 1 | type error = 2 | | InvalidCode 3 | | ExpiredCode; 4 | 5 | let getFreeBurgers = (_: list(Item.t)) => Error(`NeedTwoBurgers); 6 | 7 | let getHalfOff = (_: list(Item.t), ~date as _: Js.Date.t) => 8 | Error(`NeedMegaBurger); 9 | let getSandwichHalfOff = getHalfOff; 10 | 11 | // #region get-discount-function 12 | let getDiscountFunction = (code, date) => { 13 | let month = date |> Js.Date.getMonth; 14 | let dayOfMonth = date |> Js.Date.getDate; 15 | 16 | switch (code |> Js.String.toUpperCase) { 17 | | "FREE" when month == 4.0 => Ok(getFreeBurgers) 18 | | "HALF" when month == 4.0 && dayOfMonth == 28.0 => Ok(getHalfOff(~date)) 19 | | "HALF" when month == 10.0 && dayOfMonth == 3.0 => 20 | Ok(getSandwichHalfOff(~date)) 21 | | "FREE" 22 | | "HALF" => Error(ExpiredCode) 23 | | _ => Error(InvalidCode) 24 | }; 25 | }; 26 | // #endregion get-discount-function 27 | 28 | ignore(getDiscountFunction); 29 | 30 | // #region get-discount-function-pair 31 | let getDiscountPair = (code, date) => { 32 | let month = date |> Js.Date.getMonth; 33 | let dayOfMonth = date |> Js.Date.getDate; 34 | 35 | switch (code |> Js.String.toUpperCase) { 36 | | "FREE" when month == 4.0 => Ok((`FreeBurgers, getFreeBurgers)) 37 | | "HALF" when month == 4.0 && dayOfMonth == 28.0 => 38 | Ok((`HalfOff, getHalfOff(~date))) 39 | | "HALF" when month == 10.0 && dayOfMonth == 3.0 => 40 | Ok((`SandwichHalfOff, getSandwichHalfOff(~date))) 41 | | "FREE" 42 | | "HALF" => Error(ExpiredCode) 43 | | _ => Error(InvalidCode) 44 | }; 45 | }; 46 | 47 | let getDiscountFunction = (code, date) => 48 | getDiscountPair(code, date) |> Result.map(snd); 49 | // #endregion get-discount-function-pair 50 | -------------------------------------------------------------------------------- /docs/promo-component/DiscountTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | module GetDiscount' = { 4 | // #region test-half-promo 5 | test( 6 | "HALF promo code returns getHalfOff on May 28 but not other days of May", 7 | () => { 8 | for (dayOfMonth in 1 to 31) { 9 | let date = 10 | Js.Date.makeWithYMD( 11 | ~year=2024., 12 | ~month=4.0, 13 | ~date=float_of_int(dayOfMonth), 14 | ); 15 | 16 | expect 17 | |> deepEqual( 18 | Discount.getDiscountFunction("HALF", date), 19 | dayOfMonth == 28 20 | ? Ok(Discount.getHalfOff(~date)) : Error(ExpiredCode), 21 | ); 22 | } 23 | }); 24 | // #endregion test-half-promo 25 | }; 26 | 27 | // #region use-discount-function-pair 28 | module GetDiscount = { 29 | let getDiscountFunction = (code, date) => 30 | Discount.getDiscountPair(code, date) |> Result.map(fst); 31 | 32 | // ... 33 | 34 | test( 35 | "HALF promo code returns getHalfOff on May 28 but not other days of May", 36 | () => { 37 | for (dayOfMonth in 1 to 31) { 38 | let date = 39 | Js.Date.makeWithYMD( 40 | ~year=2024., 41 | ~month=4.0, 42 | ~date=float_of_int(dayOfMonth), 43 | ); 44 | 45 | expect 46 | |> deepEqual( 47 | getDiscountFunction("HALF", date), 48 | dayOfMonth == 28 ? Ok(`HalfOff) : Error(ExpiredCode), 49 | ); 50 | } 51 | }); 52 | // ... 53 | }; 54 | // #endregion use-discount-function-pair 55 | -------------------------------------------------------------------------------- /docs/promo-component/Index.re: -------------------------------------------------------------------------------- 1 | let items = []; 2 | 3 | module Order = { 4 | [@react.component] 5 | let make = (~items as _) =>
; 6 | }; 7 | 8 | // #region make 9 | [@react.component] 10 | let make = () => 11 |
12 |

{RR.s("Promo")}

13 | 14 |

{RR.s("Order confirmation")}

15 | 16 |
; 17 | // #endregion make 18 | -------------------------------------------------------------------------------- /docs/promo-component/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | }; 10 | 11 | module Sandwich = { 12 | type t = 13 | | Portabello 14 | | Ham 15 | | Unicorn 16 | | Turducken; 17 | }; 18 | 19 | type t = 20 | | Sandwich(Sandwich.t) 21 | | Burger(Burger.t) 22 | | Hotdog; 23 | 24 | let toPrice = (_t: t) => 0.; 25 | 26 | let toEmoji = (_t: t) => ""; 27 | -------------------------------------------------------------------------------- /docs/promo-component/Order.re: -------------------------------------------------------------------------------- 1 | type t = list(Item.t); 2 | 3 | module OrderItem = { 4 | module Style = { 5 | let item = [%cx {|border-top: 1px solid lightgray;|}]; 6 | let emoji = [%cx {|font-size: 2em;|}]; 7 | let price = [%cx {|text-align: right;|}]; 8 | }; 9 | 10 | [@react.component] 11 | let make = (~item: Item.t) => 12 | 13 | {item |> Item.toEmoji |> RR.s} 14 | {item |> Item.toPrice |> RR.currency} 15 | ; 16 | }; 17 | 18 | module Style = { 19 | let order = [%cx 20 | {| 21 | border-collapse: collapse; 22 | 23 | td { 24 | padding: 0.5em; 25 | } 26 | |} 27 | ]; 28 | 29 | let total = [%cx 30 | {| 31 | border-top: 1px solid gray; 32 | font-weight: bold; 33 | text-align: right; 34 | |} 35 | ]; 36 | }; 37 | 38 | [@react.component] 39 | let make = (~items: t) => { 40 | let total = 41 | items 42 | |> ListLabels.fold_left(~init=0., ~f=(acc, order) => 43 | acc +. Item.toPrice(order) 44 | ); 45 | 46 | 47 | 48 | {items 49 | |> List.mapi((index, item) => 50 | 51 | ) 52 | |> RR.list} 53 | 54 | 55 | 56 | 57 | 58 |
{RR.s("Total")} {total |> RR.currency}
; 59 | }; 60 | -------------------------------------------------------------------------------- /docs/promo-component/RR.re: -------------------------------------------------------------------------------- 1 | // #region initial-functions 2 | /** Get string value from the given event's target */ 3 | let getValueFromEvent = (evt): string => React.Event.Form.target(evt)##value; 4 | 5 | /** Alias for [React.string] */ 6 | let s = React.string; 7 | 8 | /** Render a list of [React.element]s */ 9 | let list = list => list |> Stdlib.Array.of_list |> React.array; 10 | 11 | /** Render a float as currency */ 12 | let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; 13 | // #endregion initial-functions 14 | 15 | // #region use-state-value 16 | /** Like [React.useState] but doesn't use callback functions */ 17 | let useStateValue = initial => 18 | React.useReducer((_state, newState) => newState, initial); 19 | // #endregion use-state-value 20 | -------------------------------------------------------------------------------- /docs/promo-component/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest styled-ppx.melange) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx styled-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/sandwich-tests/Item.re: -------------------------------------------------------------------------------- 1 | module Sandwich = { 2 | type t = 3 | | Portabello 4 | | Ham 5 | | Unicorn 6 | | Turducken; 7 | 8 | let toEmoji = t => 9 | Printf.sprintf( 10 | {js|🥪(%s)|js}, 11 | switch (t) { 12 | | Portabello => {js|🍄|js} 13 | | Ham => {js|🐷|js} 14 | | Unicorn => {js|🦄|js} 15 | | Turducken => {js|🦃🦆🐓|js} 16 | }, 17 | ); 18 | 19 | // #region to-price-with-date 20 | let toPrice = (~date: Js.Date.t, t) => { 21 | let day = date |> Js.Date.getDay |> int_of_float; 22 | 23 | switch (t) { 24 | | Portabello 25 | | Ham => 10. 26 | | Unicorn => 80. 27 | | Turducken when day == 2 => 10. 28 | | Turducken => 20. 29 | }; 30 | }; 31 | // #endregion to-price-with-date 32 | }; 33 | 34 | module Burger = { 35 | type t = { 36 | lettuce: bool, 37 | onions: int, 38 | cheese: int, 39 | tomatoes: bool, 40 | bacon: int, 41 | }; 42 | 43 | let toPrice = _t => 0.; 44 | }; 45 | 46 | type t = 47 | | Sandwich(Sandwich.t) 48 | | Burger(Burger.t) 49 | | Hotdog; 50 | 51 | // #region to-price 52 | let toPrice = t => { 53 | switch (t) { 54 | | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date=Js.Date.make()) 55 | | Burger(burger) => Burger.toPrice(burger) 56 | | Hotdog => 5. 57 | }; 58 | }; 59 | // #endregion to-price 60 | -------------------------------------------------------------------------------- /docs/sandwich-tests/SandwichTests.re: -------------------------------------------------------------------------------- 1 | // #region first-test 2 | Fest.test("Item.Sandwich.toEmoji", () => 3 | Fest.expect 4 | |> Fest.equal(Item.Sandwich.toEmoji(Portabello), {js|🥪(🍄)|js}) 5 | ); 6 | // #endregion first-test 7 | 8 | // #region module-alias 9 | module F = Fest; 10 | 11 | F.test("Item.Sandwich.toEmoji", () => 12 | F.expect |> F.equal(Item.Sandwich.toEmoji(Portabello), {js|🥪(🍄)|js}) 13 | ); 14 | // #endregion module-alias 15 | 16 | // #region open-fest 17 | open Fest; 18 | 19 | test("Item.Sandwich.toEmoji", () => 20 | expect |> equal(Item.Sandwich.toEmoji(Portabello), {js|🥪(🍄)|js}) 21 | ); 22 | // #endregion open-fest 23 | 24 | // #region broken-test 25 | test("Item.Sandwich.toEmoji", () => 26 | expect |> equal(Item.Sandwich.toEmoji(Portabello), {js|🥪(🐷)|js}) 27 | ); 28 | // #endregion broken-test 29 | 30 | // #region array-of-sandwiches 31 | let sandwiches: array(Item.Sandwich.t) = [| 32 | Portabello, 33 | Ham, 34 | Unicorn, 35 | Turducken, 36 | |]; 37 | // #endregion array-of-sandwiches 38 | 39 | // #region test-to-price 40 | test("Item.Sandwich.toPrice", () => { 41 | let sandwiches: array(Item.Sandwich.t) = [| 42 | Portabello, 43 | Ham, 44 | Unicorn, 45 | Turducken, 46 | |]; 47 | 48 | // 14 Feb 2024 is a Wednesday 49 | let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.); 50 | 51 | expect 52 | |> deepEqual( 53 | sandwiches 54 | |> Js.Array.map(~f=item => Item.Sandwich.toPrice(~date, item)), 55 | [|10., 10., 80., 20.|] /* expected prices */ 56 | ); 57 | }); 58 | // #endregion test-to-price 59 | 60 | // #region test-to-price-type-inference 61 | test("Item.Sandwich.toPrice", () => { 62 | // 14 Feb 2024 is a Wednesday 63 | let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.); 64 | 65 | expect 66 | |> deepEqual( 67 | [|Portabello, Ham, Unicorn, Turducken|] 68 | |> Js.Array.map(~f=item => Item.Sandwich.toPrice(~date, item)), 69 | [|10., 10., 80., 20.|], 70 | ); 71 | }); 72 | // #endregion test-to-price-type-inference 73 | 74 | // #region test-to-emoji 75 | test("Item.Sandwich.toEmoji", () => { 76 | expect 77 | |> deepEqual( 78 | [|Portabello, Ham, Unicorn, Turducken|] 79 | |> Js.Array.map(~f=Item.Sandwich.toEmoji), 80 | [| 81 | {js|🥪(🍄)|js}, 82 | {js|🥪(🐷)|js}, 83 | {js|🥪(🦄)|js}, 84 | {js|🥪(🦃🦆🐓)|js}, 85 | |], 86 | ) 87 | }); 88 | // #endregion test-to-emoji 89 | 90 | // #region test-to-price-turducken 91 | test("Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays", () => { 92 | // Make an array of all dates in a single week; 1 Jan 2024 is a Monday 93 | let dates = 94 | [|1., 2., 3., 4., 5., 6., 7.|] 95 | |> Js.Array.map(~f=date => 96 | Js.Date.makeWithYMD(~year=2024., ~month=0., ~date) 97 | ); 98 | 99 | expect 100 | |> deepEqual( 101 | dates 102 | |> Js.Array.map(~f=date => Item.Sandwich.toPrice(~date, Turducken)), 103 | [|20., 10., 20., 20., 20., 20., 20.|], 104 | ); 105 | }); 106 | // #endregion test-to-price-turducken 107 | 108 | // #region test-to-price-exercise-solution 109 | test("Item.Sandwich.toPrice", () => { 110 | let f = 111 | Item.Sandwich.toPrice( 112 | // 14 Feb 2024 is a Wednesday 113 | ~date=Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.), 114 | ); 115 | 116 | expect 117 | |> deepEqual( 118 | [|Portabello, Ham, Unicorn, Turducken|] |> Js.Array.map(~f), 119 | [|10., 10., 80., 20.|], 120 | ); 121 | }); 122 | // #endregion test-to-price-exercise-solution 123 | -------------------------------------------------------------------------------- /docs/sandwich-tests/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/styling-with-css/Order.re: -------------------------------------------------------------------------------- 1 | module Item = { 2 | type t = 3 | | Sandwich 4 | | Burger 5 | | Hotdog; 6 | 7 | let toPrice = 8 | fun 9 | | Sandwich => 10. 10 | | Burger => 15. 11 | | Hotdog => 5.; 12 | 13 | let toEmoji = 14 | fun 15 | | Sandwich => {js|🥪|js} 16 | | Burger => {js|🍔|js} 17 | | Hotdog => {js|🌭|js}; 18 | }; 19 | 20 | module Format = { 21 | let currency = _value => React.null; 22 | }; 23 | 24 | // #region order-item 25 | module OrderItem = { 26 | [@react.component] 27 | let make = (~item: Item.t) => 28 | 29 | {item |> Item.toEmoji |> React.string} 30 | {item |> Item.toPrice |> Format.currency} 31 | ; 32 | }; 33 | // #endregion order-item 34 | 35 | type t = array(Item.t); 36 | 37 | // #region order-make 38 | [@react.component] 39 | let make = (~items: t) => { 40 | let total = 41 | items 42 | |> Js.Array.reduce(~init=0., ~f=(acc, order) => 43 | acc +. Item.toPrice(order) 44 | ); 45 | 46 | 47 | 48 | {items 49 | |> Js.Array.mapi(~f=(item, index) => 50 | 51 | ) 52 | |> React.array} 53 | 54 | 55 | 56 | 57 | 58 |
{React.string("Total")} {total |> Format.currency}
; 59 | }; 60 | // #endregion order-make 61 | 62 | module OrderItemV2 = { 63 | // #region order-item-css 64 | module OrderItem = { 65 | [@mel.module "./order-item.module.css"] 66 | external css: Js.t({..}) = "default"; 67 | 68 | [@react.component] 69 | let make = (~item: Item.t) => 70 | 71 | {item |> Item.toEmoji |> React.string} 72 | 73 | {item |> Item.toPrice |> Format.currency} 74 | 75 | ; 76 | }; 77 | // #endregion order-item-css 78 | }; 79 | 80 | module OrderV2 = { 81 | // #region order-external 82 | [@mel.module "./order.module.css"] external css: Js.t({..}) = "default"; 83 | 84 | [@react.component] 85 | let make = (~items: t) => { 86 | let total = 87 | items 88 | |> Js.Array.reduce(~init=0., ~f=(acc, order) => 89 | acc +. Item.toPrice(order) 90 | ); 91 | 92 | 93 | 94 | {items 95 | |> Js.Array.mapi(~f=(item, index) => 96 | 97 | ) 98 | |> React.array} 99 | 100 | 101 | 102 | 103 | 104 |
{React.string("Total")} {total |> Format.currency}
; 105 | }; 106 | // #endregion order-external 107 | }; 108 | -------------------------------------------------------------------------------- /docs/styling-with-css/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /docs/todo.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: doc 3 | prev: false 4 | next: false 5 | --- 6 | 7 | # Content not yet unavailable 8 | 9 | We are hard at work writing the content you requested. Please check back later. 10 | 11 | > Docendo disco, scribendo cogito 12 | -------------------------------------------------------------------------------- /dune: -------------------------------------------------------------------------------- 1 | ; `dirs` is a stanza to tell dune which subfolders from the current folder 2 | ; (where the `dune` file is) it should process. Here it is saying to include 3 | ; all directories that don't start with . or _, but exclude node_modules. 4 | 5 | (dirs :standard \ node_modules) 6 | -------------------------------------------------------------------------------- /dune-project: -------------------------------------------------------------------------------- 1 | (lang dune 3.8) 2 | 3 | ; Use version 0.1 of the melange plugin for dune 4 | 5 | (using melange 0.1) 6 | 7 | ; Set the name which is used by error messages 8 | 9 | (name melange-for-react-devs) 10 | 11 | ; Copy all build targets for an alias into the sandbox 12 | 13 | (expand_aliases_in_sandbox) 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Developers 7 | 8 | 9 |

Melange for React Developers

10 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /melange-for-react-devs.opam: -------------------------------------------------------------------------------- 1 | opam-version: "2.0" 2 | synopsis: "Melange for React Developers" 3 | description: "Source code for Melange For React Developers book" 4 | maintainer: ["Feihong Hsu "] 5 | authors: ["Feihong Hsu "] 6 | license: "MIT" 7 | homepage: "https://github.com/melange-re/melange-for-react-devs" 8 | bug-reports: "https://github.com/melange-re/melange-for-react-devs" 9 | depends: [ 10 | "ocaml" {>= "5.1.1"} 11 | "reason" {>= "3.14.0"} 12 | "dune" {>= "3.8"} 13 | "melange" {>= "4.0.0-51"} 14 | "reason-react" {>= "0.14.0"} 15 | "reason-react-ppx" {>= "0.14.0"} 16 | "melange-fest" {>= "0.1.0"} 17 | "styled-ppx" {>= "0.59.0"} 18 | "opam-check-npm-deps" {with-test} # todo: use with-dev-setup once opam 2.2 is out 19 | "ocaml-lsp-server" {with-test} # todo: use with-dev-setup once opam 2.2 is out 20 | "dot-merlin-reader" {with-test} # todo: use with-dev-setup once opam 2.2 is out 21 | "odoc" {with-doc} 22 | ] 23 | dev-repo: "git+https://github.com/melange-re/melange-for-react-devs.git" 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "preinstall:opam": "opam update", 4 | "install:opam": "opam install -y . --deps-only --with-test", 5 | "check-npm-deps": "opam exec opam-check-npm-deps", 6 | "init": "opam switch create . 5.1.1 -y --deps-only && npm run install:npm-opam", 7 | "install:npm-opam": "npm install && npm run install:opam && npm run check-npm-deps", 8 | "dune": "opam exec -- dune", 9 | "build": "npm run dune -- build", 10 | "build:verbose": "npm run build -- --verbose", 11 | "clean": "npm run dune -- clean", 12 | "format": "npm run format:check -- --auto-promote", 13 | "format:check": "npm run dune -- build @fmt", 14 | "watch": "npm run build -- --watch", 15 | "serve": "vite serve --open", 16 | "bundle": "npm run build && vite build", 17 | "test": "npm run build -- @runtest", 18 | "test:watch": "npm run build -- @runtest --watch", 19 | "promote": "npm run dune -- promote" 20 | }, 21 | "scriptsComments": { 22 | "preinstall:opam": "# Sync opam database with upstream repositories: https://opam.ocaml.org/doc/Usage.html#opam-update", 23 | "install:opam": "# Downloads, builds and installs opam pkgs: https://opam.ocaml.org/doc/Usage.html#opam-install", 24 | "check-npm-deps": "# Checks that Melange bindings have their JS dependencies available: https://github.com/ahrefs/opam-check-npm-deps", 25 | "init": "# Create opam switch: https://opam.ocaml.org/doc/Usage.html#opam-switch and prepare everything to work in development mode (run just once, for initialization)", 26 | "install:npm-opam": "# Install both npm and opam deps", 27 | "dune": "# Run dune, OCaml's build tool", 28 | "build": "# Build the Melange apps", 29 | "build:verbose": "# Build the Melange apps in verbose mode", 30 | "clean": "# Cleans all Melange artifacts", 31 | "format": "# Formats the Melange sources using ocamlformat", 32 | "format:check": "# Checks that the Melange sources have the right formatting (read-only)", 33 | "watch": "# Watch files and rebuild when they change", 34 | "serve": "# Serves the React app in a local server", 35 | "bundle": "# Bundle the JavaScript apps generated by Melange", 36 | "test": "# Run the tests", 37 | "test:watch": "# Watch files and re-run tests", 38 | "promote": "# Promote most recent output to expected output" 39 | }, 40 | "dependencies": { 41 | "@emotion/css": "^11.11.2", 42 | "react": "^18.2.0", 43 | "react-dom": "^18.2.0" 44 | }, 45 | "devDependencies": { 46 | "@rollup/plugin-node-resolve": "^15.2.3", 47 | "tree-node-cli": "^1.6.0", 48 | "vite": "^5.0.11" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/better-burgers/Format.re: -------------------------------------------------------------------------------- 1 | let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; 2 | -------------------------------------------------------------------------------- /src/better-burgers/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | let items: Order.t = [| 3 | Sandwich(Portabello), 4 | Sandwich(Unicorn), 5 | Sandwich(Ham), 6 | Sandwich(Turducken), 7 | Hotdog, 8 | Burger({ 9 | lettuce: true, 10 | tomatoes: true, 11 | onions: 3, 12 | cheese: 2, 13 | bacon: 6, 14 | }), 15 | Burger({ 16 | lettuce: false, 17 | tomatoes: false, 18 | onions: 0, 19 | cheese: 0, 20 | bacon: 0, 21 | }), 22 | Burger({ 23 | lettuce: true, 24 | tomatoes: false, 25 | onions: 1, 26 | cheese: 1, 27 | bacon: 1, 28 | }), 29 | Burger({ 30 | lettuce: false, 31 | tomatoes: false, 32 | onions: 1, 33 | cheese: 0, 34 | bacon: 0, 35 | }), 36 | Burger({ 37 | lettuce: false, 38 | tomatoes: false, 39 | onions: 0, 40 | cheese: 1, 41 | bacon: 0, 42 | }), 43 | |]; 44 | 45 | [@react.component] 46 | let make = () => 47 |
48 |

{React.string("Order confirmation")}

49 | 50 |
; 51 | }; 52 | 53 | let node = ReactDOM.querySelector("#root"); 54 | switch (node) { 55 | | None => 56 | Js.Console.error("Failed to start React: couldn't find the #root element") 57 | | Some(root) => 58 | let root = ReactDOM.Client.createRoot(root); 59 | ReactDOM.Client.render(root, ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/better-burgers/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | 10 | let toEmoji = t => { 11 | let multiple = (emoji, count) => 12 | switch (count) { 13 | | 0 => "" 14 | | 1 => emoji 15 | | count => Printf.sprintf({js|%s×%d|js}, emoji, count) 16 | }; 17 | 18 | switch (t) { 19 | | {lettuce: false, onions: 0, cheese: 0, tomatoes: false, bacon: 0} => {js|🍔|js} 20 | | {lettuce, onions, cheese, tomatoes, bacon} => 21 | Printf.sprintf( 22 | {js|🍔{%s}|js}, 23 | [| 24 | lettuce ? {js|🥬|js} : "", 25 | tomatoes ? {js|🍅|js} : "", 26 | multiple({js|🧅|js}, onions), 27 | multiple({js|🧀|js}, cheese), 28 | multiple({js|🥓|js}, bacon), 29 | |] 30 | |> Js.Array.filter(~f=str => str != "") 31 | |> Js.Array.join(~sep=","), 32 | ) 33 | }; 34 | }; 35 | 36 | let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { 37 | let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; 38 | 39 | 15. // base cost 40 | +. toppingCost(onions, 0.2) 41 | +. toppingCost(cheese, 0.1) 42 | +. (tomatoes ? 0.05 : 0.0) 43 | +. toppingCost(bacon, 0.5); 44 | }; 45 | }; 46 | 47 | module Sandwich = { 48 | type t = 49 | | Portabello 50 | | Ham 51 | | Unicorn 52 | | Turducken; 53 | 54 | let toPrice = t => { 55 | let day = Js.Date.make() |> Js.Date.getDay |> int_of_float; 56 | 57 | switch (t) { 58 | | Portabello 59 | | Ham => 10. 60 | | Unicorn => 80. 61 | | Turducken when day == 2 => 10. 62 | | Turducken => 20. 63 | }; 64 | }; 65 | 66 | let toEmoji = t => 67 | Printf.sprintf( 68 | {js|🥪(%s)|js}, 69 | switch (t) { 70 | | Portabello => {js|🍄|js} 71 | | Ham => {js|🐷|js} 72 | | Unicorn => {js|🦄|js} 73 | | Turducken => {js|🦃🦆🐓|js} 74 | }, 75 | ); 76 | }; 77 | 78 | type t = 79 | | Sandwich(Sandwich.t) 80 | | Burger(Burger.t) 81 | | Hotdog; 82 | 83 | let toPrice = t => { 84 | switch (t) { 85 | | Sandwich(sandwich) => Sandwich.toPrice(sandwich) 86 | | Burger(burger) => Burger.toPrice(burger) 87 | | Hotdog => 5. 88 | }; 89 | }; 90 | 91 | let toEmoji = 92 | fun 93 | | Hotdog => {js|🌭|js} 94 | | Burger(burger) => Burger.toEmoji(burger) 95 | | Sandwich(sandwich) => Sandwich.toEmoji(sandwich); 96 | -------------------------------------------------------------------------------- /src/better-burgers/Order.re: -------------------------------------------------------------------------------- 1 | type t = array(Item.t); 2 | 3 | module OrderItem = { 4 | [@mel.module "./order-item.module.css"] 5 | external css: Js.t({..}) = "default"; 6 | 7 | [@react.component] 8 | let make = (~item: Item.t) => 9 | 10 | {item |> Item.toEmoji |> React.string} 11 | {item |> Item.toPrice |> Format.currency} 12 | ; 13 | }; 14 | 15 | [@mel.module "./order.module.css"] external css: Js.t({..}) = "default"; 16 | 17 | [@react.component] 18 | let make = (~items: t) => { 19 | let total = 20 | items 21 | |> Js.Array.reduce(~init=0., ~f=(acc, order) => 22 | acc +. Item.toPrice(order) 23 | ); 24 | 25 | 26 | 27 | {items 28 | |> Js.Array.mapi(~f=(item, index) => 29 | 30 | ) 31 | |> React.array} 32 | 33 | 34 | 35 | 36 | 37 |
{React.string("Total")} {total |> Format.currency}
; 38 | }; 39 | -------------------------------------------------------------------------------- /src/better-burgers/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6) 7 | (runtime_deps 8 | (glob_files *.css))) 9 | -------------------------------------------------------------------------------- /src/better-burgers/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/better-burgers/order-item.module.css: -------------------------------------------------------------------------------- 1 | .item { 2 | border-top: 1px solid lightgray; 3 | } 4 | 5 | .emoji { 6 | font-size: 2em; 7 | } 8 | 9 | .price { 10 | text-align: right; 11 | } 12 | -------------------------------------------------------------------------------- /src/better-burgers/order.module.css: -------------------------------------------------------------------------------- 1 | table.order { 2 | border-collapse: collapse; 3 | } 4 | 5 | table.order td { 6 | padding: 0.5em; 7 | } 8 | 9 | .total { 10 | border-top: 1px solid gray; 11 | font-weight: bold; 12 | text-align: right; 13 | } 14 | -------------------------------------------------------------------------------- /src/better-sandwiches/Format.re: -------------------------------------------------------------------------------- 1 | let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; 2 | -------------------------------------------------------------------------------- /src/better-sandwiches/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | let items: Order.t = [| 3 | Sandwich(Portabello), 4 | Burger, 5 | Sandwich(Unicorn), 6 | Hotdog, 7 | Sandwich(Ham), 8 | Sandwich(Turducken), 9 | |]; 10 | 11 | [@react.component] 12 | let make = () => 13 |
14 |

{React.string("Order confirmation")}

15 | 16 |
; 17 | }; 18 | 19 | let node = ReactDOM.querySelector("#root"); 20 | switch (node) { 21 | | None => 22 | Js.Console.error("Failed to start React: couldn't find the #root element") 23 | | Some(root) => 24 | let root = ReactDOM.Client.createRoot(root); 25 | ReactDOM.Client.render(root, ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/better-sandwiches/Item.re: -------------------------------------------------------------------------------- 1 | type sandwich = 2 | | Portabello 3 | | Ham 4 | | Unicorn 5 | | Turducken; 6 | 7 | type t = 8 | | Sandwich(sandwich) 9 | | Burger 10 | | Hotdog; 11 | 12 | let toPrice = t => { 13 | let day = Js.Date.make() |> Js.Date.getDay |> int_of_float; 14 | 15 | switch (t) { 16 | | Sandwich(Portabello | Ham) => 10. 17 | | Sandwich(Unicorn) => 80. 18 | | Sandwich(Turducken) when day == 2 => 10. 19 | | Sandwich(Turducken) => 20. 20 | | Burger => 15. 21 | | Hotdog => 5. 22 | }; 23 | }; 24 | 25 | let toEmoji = 26 | fun 27 | | Burger => {js|🍔|js} 28 | | Hotdog => {js|🌭|js} 29 | | Sandwich(sandwich) => 30 | Printf.sprintf( 31 | {js|🥪(%s)|js}, 32 | switch (sandwich) { 33 | | Portabello => {js|🍄|js} 34 | | Ham => {js|🐷|js} 35 | | Unicorn => {js|🦄|js} 36 | | Turducken => {js|🦃🦆🐓|js} 37 | }, 38 | ); 39 | -------------------------------------------------------------------------------- /src/better-sandwiches/Order.re: -------------------------------------------------------------------------------- 1 | type t = array(Item.t); 2 | 3 | module OrderItem = { 4 | [@mel.module "./order-item.module.css"] 5 | external css: Js.t({..}) = "default"; 6 | 7 | [@react.component] 8 | let make = (~item: Item.t) => 9 | 10 | {item |> Item.toEmoji |> React.string} 11 | {item |> Item.toPrice |> Format.currency} 12 | ; 13 | }; 14 | 15 | [@mel.module "./order.module.css"] external css: Js.t({..}) = "default"; 16 | 17 | [@react.component] 18 | let make = (~items: t) => { 19 | let total = 20 | items 21 | |> Js.Array.reduce(~init=0., ~f=(acc, order) => 22 | acc +. Item.toPrice(order) 23 | ); 24 | 25 | 26 | 27 | {items 28 | |> Js.Array.mapi(~f=(item, index) => 29 | 30 | ) 31 | |> React.array} 32 | 33 | 34 | 35 | 36 | 37 |
{React.string("Total")} {total |> Format.currency}
; 38 | }; 39 | -------------------------------------------------------------------------------- /src/better-sandwiches/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6) 7 | (runtime_deps 8 | (glob_files *.css))) 9 | -------------------------------------------------------------------------------- /src/better-sandwiches/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/better-sandwiches/order-item.module.css: -------------------------------------------------------------------------------- 1 | .item { 2 | border-top: 1px solid lightgray; 3 | } 4 | 5 | .emoji { 6 | font-size: 2em; 7 | } 8 | 9 | .price { 10 | text-align: right; 11 | } 12 | -------------------------------------------------------------------------------- /src/better-sandwiches/order.module.css: -------------------------------------------------------------------------------- 1 | table.order { 2 | border-collapse: collapse; 3 | } 4 | 5 | table.order td { 6 | padding: 0.5em; 7 | } 8 | 9 | .total { 10 | border-top: 1px solid gray; 11 | font-weight: bold; 12 | text-align: right; 13 | } 14 | -------------------------------------------------------------------------------- /src/burger-discounts/Array.re: -------------------------------------------------------------------------------- 1 | // Safe array access function 2 | let get: (array('a), int) => option('a) = 3 | (array, index) => 4 | switch (index) { 5 | | index when index < 0 || index >= Js.Array.length(array) => None 6 | | index => Some(Stdlib.Array.get(array, index)) 7 | }; 8 | -------------------------------------------------------------------------------- /src/burger-discounts/BurgerTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | test("A fully-loaded burger", () => 4 | expect 5 | |> equal( 6 | Item.Burger.toEmoji({ 7 | lettuce: true, 8 | onions: 2, 9 | cheese: 3, 10 | tomatoes: true, 11 | bacon: 4, 12 | }), 13 | {js|🍔{🥬,🍅,🧅×2,🧀×3,🥓×4}|js}, 14 | ) 15 | ); 16 | 17 | test("Burger with 0 of onions, cheese, or bacon doesn't show those emoji", () => 18 | expect 19 | |> equal( 20 | Item.Burger.toEmoji({ 21 | lettuce: true, 22 | tomatoes: true, 23 | onions: 0, 24 | cheese: 0, 25 | bacon: 0, 26 | }), 27 | {js|🍔{🥬,🍅}|js}, 28 | ) 29 | ); 30 | 31 | test( 32 | "Burger with 1 of onions, cheese, or bacon should show just the emoji without ×", 33 | () => 34 | expect 35 | |> equal( 36 | Item.Burger.toEmoji({ 37 | lettuce: true, 38 | tomatoes: true, 39 | onions: 1, 40 | cheese: 1, 41 | bacon: 1, 42 | }), 43 | {js|🍔{🥬,🍅,🧅,🧀,🥓}|js}, 44 | ) 45 | ); 46 | 47 | test("Burger with 2 or more of onions, cheese, or bacon should show ×", () => 48 | expect 49 | |> equal( 50 | Item.Burger.toEmoji({ 51 | lettuce: true, 52 | tomatoes: true, 53 | onions: 2, 54 | cheese: 2, 55 | bacon: 2, 56 | }), 57 | {js|🍔{🥬,🍅,🧅×2,🧀×2,🥓×2}|js}, 58 | ) 59 | ); 60 | 61 | test("Burger with more than 12 toppings should also show bowl emoji", () => { 62 | expect 63 | |> equal( 64 | Item.Burger.toEmoji({ 65 | lettuce: true, 66 | tomatoes: true, 67 | onions: 4, 68 | cheese: 2, 69 | bacon: 5, 70 | }), 71 | {js|🍔🥣{🥬,🍅,🧅×4,🧀×2,🥓×5}|js}, 72 | ); 73 | 74 | expect 75 | |> equal( 76 | Item.Burger.toEmoji({ 77 | lettuce: true, 78 | tomatoes: true, 79 | onions: 4, 80 | cheese: 2, 81 | bacon: 4, 82 | }), 83 | {js|🍔{🥬,🍅,🧅×4,🧀×2,🥓×4}|js}, 84 | ); 85 | }); 86 | -------------------------------------------------------------------------------- /src/burger-discounts/Discount.re: -------------------------------------------------------------------------------- 1 | // Buy 2 burgers, get 1 free 2 | let getFreeBurger = (items: array(Item.t)) => { 3 | let prices = 4 | items 5 | |> Js.Array.filter(~f=item => 6 | switch (item) { 7 | | Item.Burger(_) => true 8 | | Sandwich(_) 9 | | Hotdog => false 10 | } 11 | ) 12 | |> Js.Array.map(~f=Item.toPrice) 13 | |> Js.Array.sortInPlaceWith(~f=(x, y) => - compare(x, y)); 14 | 15 | prices[1]; 16 | }; 17 | 18 | // Buy 1+ burger with 1+ of every topping, get half off 19 | let getHalfOff = (items: array(Item.t)) => { 20 | let meetsCondition = 21 | items 22 | |> Js.Array.some( 23 | ~f= 24 | fun 25 | | Item.Burger({ 26 | lettuce: true, 27 | tomatoes: true, 28 | onions, 29 | cheese, 30 | bacon, 31 | }) 32 | when onions > 0 && cheese > 0 && bacon > 0 => 33 | true 34 | | Burger(_) 35 | | Sandwich(_) 36 | | Hotdog => false, 37 | ); 38 | 39 | switch (meetsCondition) { 40 | | false => None 41 | | true => 42 | let total = 43 | items 44 | |> Js.Array.reduce(~init=0.0, ~f=(total, item) => 45 | total +. Item.toPrice(item) 46 | ); 47 | Some(total /. 2.0); 48 | }; 49 | }; 50 | -------------------------------------------------------------------------------- /src/burger-discounts/Format.re: -------------------------------------------------------------------------------- 1 | let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; 2 | -------------------------------------------------------------------------------- /src/burger-discounts/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | let items: Order.t = [| 3 | Sandwich(Portabello), 4 | Sandwich(Unicorn), 5 | Sandwich(Ham), 6 | Sandwich(Turducken), 7 | Hotdog, 8 | Burger({ 9 | lettuce: true, 10 | tomatoes: true, 11 | onions: 3, 12 | cheese: 2, 13 | bacon: 6, 14 | }), 15 | Burger({ 16 | lettuce: false, 17 | tomatoes: false, 18 | onions: 0, 19 | cheese: 0, 20 | bacon: 0, 21 | }), 22 | Burger({ 23 | lettuce: true, 24 | tomatoes: false, 25 | onions: 1, 26 | cheese: 1, 27 | bacon: 1, 28 | }), 29 | Burger({ 30 | lettuce: false, 31 | tomatoes: false, 32 | onions: 1, 33 | cheese: 0, 34 | bacon: 0, 35 | }), 36 | Burger({ 37 | lettuce: false, 38 | tomatoes: false, 39 | onions: 0, 40 | cheese: 1, 41 | bacon: 0, 42 | }), 43 | |]; 44 | 45 | [@react.component] 46 | let make = () => 47 |
48 |

{React.string("Order confirmation")}

49 | 50 |
; 51 | }; 52 | 53 | let node = ReactDOM.querySelector("#root"); 54 | switch (node) { 55 | | None => 56 | Js.Console.error("Failed to start React: couldn't find the #root element") 57 | | Some(root) => 58 | let root = ReactDOM.Client.createRoot(root); 59 | ReactDOM.Client.render(root, ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/burger-discounts/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | 10 | let toEmoji = t => { 11 | let multiple = (emoji, count) => 12 | switch (count) { 13 | | 0 => "" 14 | | 1 => emoji 15 | | count => Printf.sprintf({js|%s×%d|js}, emoji, count) 16 | }; 17 | 18 | switch (t) { 19 | | {lettuce: false, onions: 0, cheese: 0, tomatoes: false, bacon: 0} => {js|🍔|js} 20 | | {lettuce, onions, cheese, tomatoes, bacon} => 21 | let toppingsCount = 22 | (lettuce ? 1 : 0) + (tomatoes ? 1 : 0) + onions + cheese + bacon; 23 | 24 | Printf.sprintf( 25 | {js|🍔%s{%s}|js}, 26 | toppingsCount > 12 ? {js|🥣|js} : "", 27 | [| 28 | lettuce ? {js|🥬|js} : "", 29 | tomatoes ? {js|🍅|js} : "", 30 | multiple({js|🧅|js}, onions), 31 | multiple({js|🧀|js}, cheese), 32 | multiple({js|🥓|js}, bacon), 33 | |] 34 | |> Js.Array.filter(~f=str => str != "") 35 | |> Js.Array.join(~sep=","), 36 | ); 37 | }; 38 | }; 39 | 40 | let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { 41 | let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; 42 | 43 | 15. // base cost 44 | +. toppingCost(onions, 0.2) 45 | +. toppingCost(cheese, 0.1) 46 | +. (tomatoes ? 0.05 : 0.0) 47 | +. toppingCost(bacon, 0.5); 48 | }; 49 | }; 50 | 51 | module Sandwich = { 52 | type t = 53 | | Portabello 54 | | Ham 55 | | Unicorn 56 | | Turducken; 57 | 58 | let toPrice = (~date: Js.Date.t, t) => { 59 | let day = date |> Js.Date.getDay |> int_of_float; 60 | 61 | switch (t) { 62 | | Portabello 63 | | Ham => 10. 64 | | Unicorn => 80. 65 | | Turducken when day == 2 => 10. 66 | | Turducken => 20. 67 | }; 68 | }; 69 | 70 | let toEmoji = t => 71 | Printf.sprintf( 72 | {js|🥪(%s)|js}, 73 | switch (t) { 74 | | Portabello => {js|🍄|js} 75 | | Ham => {js|🐷|js} 76 | | Unicorn => {js|🦄|js} 77 | | Turducken => {js|🦃🦆🐓|js} 78 | }, 79 | ); 80 | }; 81 | 82 | type t = 83 | | Sandwich(Sandwich.t) 84 | | Burger(Burger.t) 85 | | Hotdog; 86 | 87 | let toPrice = t => { 88 | switch (t) { 89 | | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date=Js.Date.make()) 90 | | Burger(burger) => Burger.toPrice(burger) 91 | | Hotdog => 5. 92 | }; 93 | }; 94 | 95 | let toEmoji = 96 | fun 97 | | Hotdog => {js|🌭|js} 98 | | Burger(burger) => Burger.toEmoji(burger) 99 | | Sandwich(sandwich) => Sandwich.toEmoji(sandwich); 100 | -------------------------------------------------------------------------------- /src/burger-discounts/Order.re: -------------------------------------------------------------------------------- 1 | type t = array(Item.t); 2 | 3 | module OrderItem = { 4 | [@mel.module "./order-item.module.css"] 5 | external css: Js.t({..}) = "default"; 6 | 7 | [@react.component] 8 | let make = (~item: Item.t) => 9 | 10 | {item |> Item.toEmoji |> React.string} 11 | {item |> Item.toPrice |> Format.currency} 12 | ; 13 | }; 14 | 15 | [@mel.module "./order.module.css"] external css: Js.t({..}) = "default"; 16 | 17 | [@react.component] 18 | let make = (~items: t) => { 19 | let total = 20 | items 21 | |> Js.Array.reduce(~init=0., ~f=(acc, order) => 22 | acc +. Item.toPrice(order) 23 | ); 24 | 25 | 26 | 27 | {items 28 | |> Js.Array.mapi(~f=(item, index) => 29 | 30 | ) 31 | |> React.array} 32 | 33 | 34 | 35 | 36 | 37 |
{React.string("Total")} {total |> Format.currency}
; 38 | }; 39 | -------------------------------------------------------------------------------- /src/burger-discounts/SandwichTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | test("Item.Sandwich.toEmoji", () => { 4 | expect 5 | |> deepEqual( 6 | [|Portabello, Ham, Unicorn, Turducken|] 7 | |> Js.Array.map(~f=Item.Sandwich.toEmoji), 8 | [| 9 | {js|🥪(🍄)|js}, 10 | {js|🥪(🐷)|js}, 11 | {js|🥪(🦄)|js}, 12 | {js|🥪(🦃🦆🐓)|js}, 13 | |], 14 | ) 15 | }); 16 | 17 | test("Item.Sandwich.toPrice", () => { 18 | // 14 Feb 2024 is a Wednesday 19 | let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.); 20 | 21 | expect 22 | |> deepEqual( 23 | [|Portabello, Ham, Unicorn, Turducken|] 24 | |> Js.Array.map(~f=Item.Sandwich.toPrice(~date)), 25 | [|10., 10., 80., 20.|], 26 | ); 27 | }); 28 | 29 | test("Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays", () => { 30 | // Make an array of all dates in a single week; 1 Jan 2024 is a Monday 31 | let dates = 32 | [|1., 2., 3., 4., 5., 6., 7.|] 33 | |> Js.Array.map(~f=date => 34 | Js.Date.makeWithYMD(~year=2024., ~month=0., ~date) 35 | ); 36 | 37 | expect 38 | |> deepEqual( 39 | dates 40 | |> Js.Array.map(~f=date => Item.Sandwich.toPrice(Turducken, ~date)), 41 | [|20., 10., 20., 20., 20., 20., 20.|], 42 | ); 43 | }); 44 | -------------------------------------------------------------------------------- /src/burger-discounts/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems 7 | (es6 mjs)) 8 | (runtime_deps 9 | (glob_files *.css))) 10 | 11 | (cram 12 | (deps 13 | (alias melange))) 14 | -------------------------------------------------------------------------------- /src/burger-discounts/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/burger-discounts/order-item.module.css: -------------------------------------------------------------------------------- 1 | .item { 2 | border-top: 1px solid lightgray; 3 | } 4 | 5 | .emoji { 6 | font-size: 2em; 7 | } 8 | 9 | .price { 10 | text-align: right; 11 | } 12 | -------------------------------------------------------------------------------- /src/burger-discounts/order.module.css: -------------------------------------------------------------------------------- 1 | table.order { 2 | border-collapse: collapse; 3 | } 4 | 5 | table.order td { 6 | padding: 0.5em; 7 | } 8 | 9 | .total { 10 | border-top: 1px solid gray; 11 | font-weight: bold; 12 | text-align: right; 13 | } 14 | -------------------------------------------------------------------------------- /src/burger-discounts/tests.t: -------------------------------------------------------------------------------- 1 | Sandwich tests 2 | $ node ./output/src/burger-discounts/SandwichTests.mjs | sed '/duration_ms/d' 3 | TAP version 13 4 | # Subtest: Item.Sandwich.toEmoji 5 | ok 1 - Item.Sandwich.toEmoji 6 | --- 7 | ... 8 | # Subtest: Item.Sandwich.toPrice 9 | ok 2 - Item.Sandwich.toPrice 10 | --- 11 | ... 12 | # Subtest: Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 13 | ok 3 - Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 14 | --- 15 | ... 16 | 1..3 17 | # tests 3 18 | # suites 0 19 | # pass 3 20 | # fail 0 21 | # cancelled 0 22 | # skipped 0 23 | # todo 0 24 | 25 | Burger tests 26 | $ node ./output/src/burger-discounts/BurgerTests.mjs | sed '/duration_ms/d' 27 | TAP version 13 28 | # Subtest: A fully-loaded burger 29 | ok 1 - A fully-loaded burger 30 | --- 31 | ... 32 | # Subtest: Burger with 0 of onions, cheese, or bacon doesn't show those emoji 33 | ok 2 - Burger with 0 of onions, cheese, or bacon doesn't show those emoji 34 | --- 35 | ... 36 | # Subtest: Burger with 1 of onions, cheese, or bacon should show just the emoji without × 37 | ok 3 - Burger with 1 of onions, cheese, or bacon should show just the emoji without × 38 | --- 39 | ... 40 | # Subtest: Burger with 2 or more of onions, cheese, or bacon should show × 41 | ok 4 - Burger with 2 or more of onions, cheese, or bacon should show × 42 | --- 43 | ... 44 | # Subtest: Burger with more than 12 toppings should also show bowl emoji 45 | ok 5 - Burger with more than 12 toppings should also show bowl emoji 46 | --- 47 | ... 48 | 1..5 49 | # tests 5 50 | # suites 0 51 | # pass 5 52 | # fail 0 53 | # cancelled 0 54 | # skipped 0 55 | # todo 0 56 | 57 | Discount tests 58 | $ node ./output/src/burger-discounts/DiscountTests.mjs | sed '/duration_ms/d' 59 | TAP version 13 60 | # Subtest: 0 burgers, no discount 61 | ok 1 - 0 burgers, no discount 62 | --- 63 | ... 64 | # Subtest: 1 burger, no discount 65 | ok 2 - 1 burger, no discount 66 | --- 67 | ... 68 | # Subtest: 2 burgers of same price, discount 69 | ok 3 - 2 burgers of same price, discount 70 | --- 71 | ... 72 | # Subtest: 2 burgers of different price, discount of cheaper one 73 | ok 4 - 2 burgers of different price, discount of cheaper one 74 | --- 75 | ... 76 | # Subtest: Input array isn't changed 77 | ok 5 - Input array isn't changed 78 | --- 79 | ... 80 | # Subtest: 3 burgers of different price, return Some(15.15) 81 | ok 6 - 3 burgers of different price, return Some(15.15) 82 | --- 83 | ... 84 | # Subtest: No burger has 1+ of every topping, return None 85 | ok 7 - No burger has 1+ of every topping, return None 86 | --- 87 | ... 88 | # Subtest: One burger has 1+ of every topping, return Some 89 | ok 8 - One burger has 1+ of every topping, return Some 90 | --- 91 | ... 92 | 1..8 93 | # tests 8 94 | # suites 0 95 | # pass 8 96 | # fail 0 97 | # cancelled 0 98 | # skipped 0 99 | # todo 0 100 | -------------------------------------------------------------------------------- /src/celsius-converter-exception/CelsiusConverter.re: -------------------------------------------------------------------------------- 1 | let getValueFromEvent = (evt): string => React.Event.Form.target(evt)##value; 2 | 3 | let convert = celsius => 9.0 /. 5.0 *. celsius +. 32.0; 4 | 5 | [@react.component] 6 | let make = () => { 7 | let (celsius, setCelsius) = React.useState(() => ""); 8 | 9 |
10 | { 13 | let newCelsius = getValueFromEvent(evt); 14 | setCelsius(_ => newCelsius); 15 | }} 16 | /> 17 | {React.string({js|°C = |js})} 18 | {( 19 | celsius == "" 20 | ? {js|?°F|js} 21 | : ( 22 | switch ( 23 | celsius 24 | |> float_of_string 25 | |> convert 26 | |> Js.Float.toFixed(~digits=2) 27 | ) { 28 | | exception _ => "error" 29 | | fahrenheit => fahrenheit ++ {js|°F|js} 30 | } 31 | ) 32 | ) 33 | |> React.string} 34 |
; 35 | }; 36 | -------------------------------------------------------------------------------- /src/celsius-converter-exception/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | [@react.component] 3 | let make = () => 4 |
5 |

{React.string("Celsius Converter")}

6 | 7 |
; 8 | }; 9 | 10 | let node = ReactDOM.querySelector("#root"); 11 | switch (node) { 12 | | None => 13 | Js.Console.error("Failed to start React: couldn't find the #root element") 14 | | Some(root) => 15 | let root = ReactDOM.Client.createRoot(root); 16 | ReactDOM.Client.render(root, ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/celsius-converter-exception/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /src/celsius-converter-exception/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/celsius-converter-option/CelsiusConverter.re: -------------------------------------------------------------------------------- 1 | let getValueFromEvent = (evt): string => React.Event.Form.target(evt)##value; 2 | 3 | let convert = celsius => 9.0 /. 5.0 *. celsius +. 32.0; 4 | 5 | [@react.component] 6 | let make = () => { 7 | let (celsius, setCelsius) = React.useState(() => ""); 8 | 9 |
10 | { 13 | let newCelsius = getValueFromEvent(evt); 14 | setCelsius(_ => newCelsius); 15 | }} 16 | /> 17 | {React.string({js|°C = |js})} 18 | {( 19 | String.trim(celsius) == "" 20 | ? {js|?°F|js} 21 | : ( 22 | switch (celsius |> float_of_string_opt |> Option.map(convert)) { 23 | | None => "error" 24 | | Some(fahrenheit) when fahrenheit < (-128.6) => {js|Unreasonably cold🥶|js} 25 | | Some(fahrenheit) when fahrenheit > 212.0 => {js|Unreasonably hot🥵|js} 26 | | Some(fahrenheit) => 27 | Js.Float.toFixed(fahrenheit, ~digits=2) ++ {js|°F|js} 28 | } 29 | ) 30 | ) 31 | |> React.string} 32 |
; 33 | }; 34 | -------------------------------------------------------------------------------- /src/celsius-converter-option/CelsiusConverter_FloatFromString.re: -------------------------------------------------------------------------------- 1 | // Solution to exercise 3 2 | 3 | let getValueFromEvent = (evt): string => React.Event.Form.target(evt)##value; 4 | 5 | let convert = celsius => 9.0 /. 5.0 *. celsius +. 32.0; 6 | 7 | let floatFromString = text => { 8 | let value = Js.Float.fromString(text); 9 | Js.Float.isNaN(value) ? None : Some(value); 10 | }; 11 | 12 | [@react.component] 13 | let make = () => { 14 | let (celsius, setCelsius) = React.useState(() => ""); 15 | 16 |
17 | { 20 | let newCelsius = getValueFromEvent(evt); 21 | setCelsius(_ => newCelsius); 22 | }} 23 | /> 24 | {React.string({js|°C = |js})} 25 | {( 26 | String.trim(celsius) == "" 27 | ? {js|?°F|js} 28 | : ( 29 | switch (celsius |> floatFromString |> Option.map(convert)) { 30 | | None => "error" 31 | | Some(fahrenheit) when fahrenheit < (-128.6) => {js|Unreasonably cold🥶|js} 32 | | Some(fahrenheit) when fahrenheit > 212.0 => {js|Unreasonably hot🥵|js} 33 | | Some(fahrenheit) => 34 | Js.Float.toFixed(fahrenheit, ~digits=2) ++ {js|°F|js} 35 | } 36 | ) 37 | ) 38 | |> React.string} 39 |
; 40 | }; 41 | -------------------------------------------------------------------------------- /src/celsius-converter-option/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | [@react.component] 3 | let make = () => 4 |
5 |

{React.string("Celsius Converter Using Option")}

6 | 7 |

{React.string("Using Float.fromString")}

8 | 9 |
; 10 | }; 11 | 12 | let node = ReactDOM.querySelector("#root"); 13 | switch (node) { 14 | | None => 15 | Js.Console.error("Failed to start React: couldn't find the #root element") 16 | | Some(root) => 17 | let root = ReactDOM.Client.createRoot(root); 18 | ReactDOM.Client.render(root, ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/celsius-converter-option/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /src/celsius-converter-option/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/counter/Counter.re: -------------------------------------------------------------------------------- 1 | [@react.component] 2 | let make = () => { 3 | let (counter, setCounter) = React.useState(() => 0); 4 | 5 |
12 | 15 | {counter |> Int.to_string |> React.string} 16 | 19 |
; 20 | }; 21 | -------------------------------------------------------------------------------- /src/counter/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | [@react.component] 3 | let make = () => 4 |

{React.string("Counter")}

; 5 | }; 6 | 7 | let node = ReactDOM.querySelector("#root"); 8 | switch (node) { 9 | | None => 10 | Js.Console.error("Failed to start React: couldn't find the #root element") 11 | | Some(root) => 12 | let root = ReactDOM.Client.createRoot(root); 13 | ReactDOM.Client.render(root, ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/counter/dune: -------------------------------------------------------------------------------- 1 | ; `melange.emit` is a Dune stanza that will produce build rules to generate 2 | ; JavaScript files from sources using the Melange compiler 3 | ; https://dune.readthedocs.io/en/stable/melange.html#melange-emit 4 | 5 | (melange.emit 6 | ; The `target` field is used by Dune to put all JavaScript artifacts in a 7 | ; specific folder inside `_build/default` 8 | (target output) 9 | ; Here's the list of dependencies of the stanza. In this case (being 10 | ; `melange.emit`), Dune will look into those dependencies and generate rules 11 | ; with JavaScript targets for the modules in those libraries as well. 12 | ; Caveat: the libraries need to be specified with `(modes melange)`. 13 | (libraries reason-react) 14 | ; The `preprocess` field lists preprocessors which transform code before it is 15 | ; compiled. These enable, for example, the use of JSX in .re files. 16 | (preprocess 17 | (pps melange.ppx reason-react-ppx)) 18 | ; module_systems lets you specify commonjs (the default) or es6 19 | (module_systems es6)) 20 | -------------------------------------------------------------------------------- /src/counter/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/cram-tests/BurgerTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | test("A fully-loaded burger", () => 4 | expect 5 | |> equal( 6 | Item.Burger.toEmoji({ 7 | lettuce: true, 8 | onions: 2, 9 | cheese: 3, 10 | tomatoes: true, 11 | bacon: 4, 12 | }), 13 | {js|🍔{🥬,🍅,🧅×2,🧀×3,🥓×4}|js}, 14 | ) 15 | ); 16 | 17 | test("Burger with 0 of onions, cheese, or bacon doesn't show those emoji", () => 18 | expect 19 | |> equal( 20 | Item.Burger.toEmoji({ 21 | lettuce: true, 22 | tomatoes: true, 23 | onions: 0, 24 | cheese: 0, 25 | bacon: 0, 26 | }), 27 | {js|🍔{🥬,🍅}|js}, 28 | ) 29 | ); 30 | 31 | test( 32 | "Burger with 1 of onions, cheese, or bacon should show just the emoji without ×", 33 | () => 34 | expect 35 | |> equal( 36 | Item.Burger.toEmoji({ 37 | lettuce: true, 38 | tomatoes: true, 39 | onions: 1, 40 | cheese: 1, 41 | bacon: 1, 42 | }), 43 | {js|🍔{🥬,🍅,🧅,🧀,🥓}|js}, 44 | ) 45 | ); 46 | 47 | test("Burger with 2 or more of onions, cheese, or bacon should show ×", () => 48 | expect 49 | |> equal( 50 | Item.Burger.toEmoji({ 51 | lettuce: true, 52 | tomatoes: true, 53 | onions: 2, 54 | cheese: 2, 55 | bacon: 2, 56 | }), 57 | {js|🍔{🥬,🍅,🧅×2,🧀×2,🥓×2}|js}, 58 | ) 59 | ); 60 | 61 | test("Burger with more than 12 toppings should also show bowl emoji", () => { 62 | expect 63 | |> equal( 64 | Item.Burger.toEmoji({ 65 | lettuce: true, 66 | tomatoes: true, 67 | onions: 4, 68 | cheese: 2, 69 | bacon: 5, 70 | }), 71 | {js|🍔🥣{🥬,🍅,🧅×4,🧀×2,🥓×5}|js}, 72 | ); 73 | 74 | expect 75 | |> equal( 76 | Item.Burger.toEmoji({ 77 | lettuce: true, 78 | tomatoes: true, 79 | onions: 4, 80 | cheese: 2, 81 | bacon: 4, 82 | }), 83 | {js|🍔{🥬,🍅,🧅×4,🧀×2,🥓×4}|js}, 84 | ); 85 | }); 86 | -------------------------------------------------------------------------------- /src/cram-tests/Format.re: -------------------------------------------------------------------------------- 1 | let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; 2 | -------------------------------------------------------------------------------- /src/cram-tests/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | let items: Order.t = [| 3 | Sandwich(Portabello), 4 | Sandwich(Unicorn), 5 | Sandwich(Ham), 6 | Sandwich(Turducken), 7 | Hotdog, 8 | Burger({ 9 | lettuce: true, 10 | tomatoes: true, 11 | onions: 3, 12 | cheese: 2, 13 | bacon: 6, 14 | }), 15 | Burger({ 16 | lettuce: false, 17 | tomatoes: false, 18 | onions: 0, 19 | cheese: 0, 20 | bacon: 0, 21 | }), 22 | Burger({ 23 | lettuce: true, 24 | tomatoes: false, 25 | onions: 1, 26 | cheese: 1, 27 | bacon: 1, 28 | }), 29 | Burger({ 30 | lettuce: false, 31 | tomatoes: false, 32 | onions: 1, 33 | cheese: 0, 34 | bacon: 0, 35 | }), 36 | Burger({ 37 | lettuce: false, 38 | tomatoes: false, 39 | onions: 0, 40 | cheese: 1, 41 | bacon: 0, 42 | }), 43 | |]; 44 | 45 | [@react.component] 46 | let make = () => 47 |
48 |

{React.string("Order confirmation")}

49 | 50 |
; 51 | }; 52 | 53 | let node = ReactDOM.querySelector("#root"); 54 | switch (node) { 55 | | None => 56 | Js.Console.error("Failed to start React: couldn't find the #root element") 57 | | Some(root) => 58 | let root = ReactDOM.Client.createRoot(root); 59 | ReactDOM.Client.render(root, ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/cram-tests/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | 10 | let toEmoji = t => { 11 | let multiple = (emoji, count) => 12 | switch (count) { 13 | | 0 => "" 14 | | 1 => emoji 15 | | count => Printf.sprintf({js|%s×%d|js}, emoji, count) 16 | }; 17 | 18 | switch (t) { 19 | | {lettuce: false, onions: 0, cheese: 0, tomatoes: false, bacon: 0} => {js|🍔|js} 20 | | {lettuce, onions, cheese, tomatoes, bacon} => 21 | let toppingsCount = 22 | (lettuce ? 1 : 0) + (tomatoes ? 1 : 0) + onions + cheese + bacon; 23 | 24 | Printf.sprintf( 25 | {js|🍔%s{%s}|js}, 26 | toppingsCount > 12 ? {js|🥣|js} : "", 27 | [| 28 | lettuce ? {js|🥬|js} : "", 29 | tomatoes ? {js|🍅|js} : "", 30 | multiple({js|🧅|js}, onions), 31 | multiple({js|🧀|js}, cheese), 32 | multiple({js|🥓|js}, bacon), 33 | |] 34 | |> Js.Array.filter(~f=str => str != "") 35 | |> Js.Array.join(~sep=","), 36 | ); 37 | }; 38 | }; 39 | 40 | let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { 41 | let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; 42 | 43 | 15. // base cost 44 | +. toppingCost(onions, 0.2) 45 | +. toppingCost(cheese, 0.1) 46 | +. (tomatoes ? 0.05 : 0.0) 47 | +. toppingCost(bacon, 0.5); 48 | }; 49 | }; 50 | 51 | module Sandwich = { 52 | type t = 53 | | Portabello 54 | | Ham 55 | | Unicorn 56 | | Turducken; 57 | 58 | let toPrice = (~date: Js.Date.t, t) => { 59 | let day = date |> Js.Date.getDay |> int_of_float; 60 | 61 | switch (t) { 62 | | Portabello 63 | | Ham => 10. 64 | | Unicorn => 80. 65 | | Turducken when day == 2 => 10. 66 | | Turducken => 20. 67 | }; 68 | }; 69 | 70 | let toEmoji = t => 71 | Printf.sprintf( 72 | {js|🥪(%s)|js}, 73 | switch (t) { 74 | | Portabello => {js|🍄|js} 75 | | Ham => {js|🐷|js} 76 | | Unicorn => {js|🦄|js} 77 | | Turducken => {js|🦃🦆🐓|js} 78 | }, 79 | ); 80 | }; 81 | 82 | type t = 83 | | Sandwich(Sandwich.t) 84 | | Burger(Burger.t) 85 | | Hotdog; 86 | 87 | let toPrice = t => { 88 | switch (t) { 89 | | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date=Js.Date.make()) 90 | | Burger(burger) => Burger.toPrice(burger) 91 | | Hotdog => 5. 92 | }; 93 | }; 94 | 95 | let toEmoji = 96 | fun 97 | | Hotdog => {js|🌭|js} 98 | | Burger(burger) => Burger.toEmoji(burger) 99 | | Sandwich(sandwich) => Sandwich.toEmoji(sandwich); 100 | -------------------------------------------------------------------------------- /src/cram-tests/Order.re: -------------------------------------------------------------------------------- 1 | type t = array(Item.t); 2 | 3 | module OrderItem = { 4 | [@mel.module "./order-item.module.css"] 5 | external css: Js.t({..}) = "default"; 6 | 7 | [@react.component] 8 | let make = (~item: Item.t) => 9 | 10 | {item |> Item.toEmoji |> React.string} 11 | {item |> Item.toPrice |> Format.currency} 12 | ; 13 | }; 14 | 15 | [@mel.module "./order.module.css"] external css: Js.t({..}) = "default"; 16 | 17 | [@react.component] 18 | let make = (~items: t) => { 19 | let total = 20 | items 21 | |> Js.Array.reduce(~init=0., ~f=(acc, order) => 22 | acc +. Item.toPrice(order) 23 | ); 24 | 25 | 26 | 27 | {items 28 | |> Js.Array.mapi(~f=(item, index) => 29 | 30 | ) 31 | |> React.array} 32 | 33 | 34 | 35 | 36 | 37 |
{React.string("Total")} {total |> Format.currency}
; 38 | }; 39 | -------------------------------------------------------------------------------- /src/cram-tests/SandwichTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | test("Item.Sandwich.toEmoji", () => { 4 | expect 5 | |> deepEqual( 6 | [|Portabello, Ham, Unicorn, Turducken|] 7 | |> Js.Array.map(~f=Item.Sandwich.toEmoji), 8 | [| 9 | {js|🥪(🍄)|js}, 10 | {js|🥪(🐷)|js}, 11 | {js|🥪(🦄)|js}, 12 | {js|🥪(🦃🦆🐓)|js}, 13 | |], 14 | ) 15 | }); 16 | 17 | test("Item.Sandwich.toPrice", () => { 18 | // 14 Feb 2024 is a Wednesday 19 | let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.); 20 | 21 | expect 22 | |> deepEqual( 23 | [|Portabello, Ham, Unicorn, Turducken|] 24 | |> Js.Array.map(~f=Item.Sandwich.toPrice(~date)), 25 | [|10., 10., 80., 20.|], 26 | ); 27 | }); 28 | 29 | test("Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays", () => { 30 | // Make an array of all dates in a single week; 1 Jan 2024 is a Monday 31 | let dates = 32 | [|1., 2., 3., 4., 5., 6., 7.|] 33 | |> Js.Array.map(~f=date => 34 | Js.Date.makeWithYMD(~year=2024., ~month=0., ~date) 35 | ); 36 | 37 | expect 38 | |> deepEqual( 39 | dates 40 | |> Js.Array.map(~f=date => Item.Sandwich.toPrice(Turducken, ~date)), 41 | [|20., 10., 20., 20., 20., 20., 20.|], 42 | ); 43 | }); 44 | -------------------------------------------------------------------------------- /src/cram-tests/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems 7 | (es6 mjs)) 8 | (runtime_deps 9 | (glob_files *.css))) 10 | 11 | (cram 12 | (deps 13 | (alias melange))) 14 | -------------------------------------------------------------------------------- /src/cram-tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/cram-tests/order-item.module.css: -------------------------------------------------------------------------------- 1 | .item { 2 | border-top: 1px solid lightgray; 3 | } 4 | 5 | .emoji { 6 | font-size: 2em; 7 | } 8 | 9 | .price { 10 | text-align: right; 11 | } 12 | -------------------------------------------------------------------------------- /src/cram-tests/order.module.css: -------------------------------------------------------------------------------- 1 | table.order { 2 | border-collapse: collapse; 3 | } 4 | 5 | table.order td { 6 | padding: 0.5em; 7 | } 8 | 9 | .total { 10 | border-top: 1px solid gray; 11 | font-weight: bold; 12 | text-align: right; 13 | } 14 | -------------------------------------------------------------------------------- /src/cram-tests/tests.t: -------------------------------------------------------------------------------- 1 | Sandwich tests 2 | $ node ./output/src/cram-tests/SandwichTests.mjs | sed '/duration_ms/d' 3 | TAP version 13 4 | # Subtest: Item.Sandwich.toEmoji 5 | ok 1 - Item.Sandwich.toEmoji 6 | --- 7 | ... 8 | # Subtest: Item.Sandwich.toPrice 9 | ok 2 - Item.Sandwich.toPrice 10 | --- 11 | ... 12 | # Subtest: Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 13 | ok 3 - Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 14 | --- 15 | ... 16 | 1..3 17 | # tests 3 18 | # suites 0 19 | # pass 3 20 | # fail 0 21 | # cancelled 0 22 | # skipped 0 23 | # todo 0 24 | 25 | Burger tests 26 | $ node ./output/src/cram-tests/BurgerTests.mjs | sed '/duration_ms/d' 27 | TAP version 13 28 | # Subtest: A fully-loaded burger 29 | ok 1 - A fully-loaded burger 30 | --- 31 | ... 32 | # Subtest: Burger with 0 of onions, cheese, or bacon doesn't show those emoji 33 | ok 2 - Burger with 0 of onions, cheese, or bacon doesn't show those emoji 34 | --- 35 | ... 36 | # Subtest: Burger with 1 of onions, cheese, or bacon should show just the emoji without × 37 | ok 3 - Burger with 1 of onions, cheese, or bacon should show just the emoji without × 38 | --- 39 | ... 40 | # Subtest: Burger with 2 or more of onions, cheese, or bacon should show × 41 | ok 4 - Burger with 2 or more of onions, cheese, or bacon should show × 42 | --- 43 | ... 44 | # Subtest: Burger with more than 12 toppings should also show bowl emoji 45 | ok 5 - Burger with more than 12 toppings should also show bowl emoji 46 | --- 47 | ... 48 | 1..5 49 | # tests 5 50 | # suites 0 51 | # pass 5 52 | # fail 0 53 | # cancelled 0 54 | # skipped 0 55 | # todo 0 56 | -------------------------------------------------------------------------------- /src/discounts-lists/Array.re: -------------------------------------------------------------------------------- 1 | /** Safe array access function */ 2 | let get: (array('a), int) => option('a) = 3 | (array, index) => 4 | switch (index) { 5 | | index when index < 0 || index >= Js.Array.length(array) => None 6 | | index => Some(Stdlib.Array.get(array, index)) 7 | }; 8 | -------------------------------------------------------------------------------- /src/discounts-lists/BurgerTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | test("A fully-loaded burger", () => 4 | expect 5 | |> equal( 6 | Item.Burger.toEmoji({ 7 | lettuce: true, 8 | onions: 2, 9 | cheese: 3, 10 | tomatoes: true, 11 | bacon: 4, 12 | }), 13 | {js|🍔{🥬,🍅,🧅×2,🧀×3,🥓×4}|js}, 14 | ) 15 | ); 16 | 17 | test("Burger with 0 of onions, cheese, or bacon doesn't show those emoji", () => 18 | expect 19 | |> equal( 20 | Item.Burger.toEmoji({ 21 | lettuce: true, 22 | tomatoes: true, 23 | onions: 0, 24 | cheese: 0, 25 | bacon: 0, 26 | }), 27 | {js|🍔{🥬,🍅}|js}, 28 | ) 29 | ); 30 | 31 | test( 32 | "Burger with 1 of onions, cheese, or bacon should show just the emoji without ×", 33 | () => 34 | expect 35 | |> equal( 36 | Item.Burger.toEmoji({ 37 | lettuce: true, 38 | tomatoes: true, 39 | onions: 1, 40 | cheese: 1, 41 | bacon: 1, 42 | }), 43 | {js|🍔{🥬,🍅,🧅,🧀,🥓}|js}, 44 | ) 45 | ); 46 | 47 | test("Burger with 2 or more of onions, cheese, or bacon should show ×", () => 48 | expect 49 | |> equal( 50 | Item.Burger.toEmoji({ 51 | lettuce: true, 52 | tomatoes: true, 53 | onions: 2, 54 | cheese: 2, 55 | bacon: 2, 56 | }), 57 | {js|🍔{🥬,🍅,🧅×2,🧀×2,🥓×2}|js}, 58 | ) 59 | ); 60 | 61 | test("Burger with more than 12 toppings should also show bowl emoji", () => { 62 | expect 63 | |> equal( 64 | Item.Burger.toEmoji({ 65 | lettuce: true, 66 | tomatoes: true, 67 | onions: 4, 68 | cheese: 2, 69 | bacon: 5, 70 | }), 71 | {js|🍔🥣{🥬,🍅,🧅×4,🧀×2,🥓×5}|js}, 72 | ); 73 | 74 | expect 75 | |> equal( 76 | Item.Burger.toEmoji({ 77 | lettuce: true, 78 | tomatoes: true, 79 | onions: 4, 80 | cheese: 2, 81 | bacon: 4, 82 | }), 83 | {js|🍔{🥬,🍅,🧅×4,🧀×2,🥓×4}|js}, 84 | ); 85 | }); 86 | -------------------------------------------------------------------------------- /src/discounts-lists/Discount.re: -------------------------------------------------------------------------------- 1 | /** Buy n burgers, get n/2 burgers free */ 2 | let getFreeBurgers = (items: list(Item.t)) => { 3 | let prices = 4 | items 5 | |> List.filter_map(item => 6 | switch (item) { 7 | | Item.Burger(burger) => Some(Item.Burger.toPrice(burger)) 8 | | Sandwich(_) 9 | | Hotdog => None 10 | } 11 | ); 12 | 13 | switch (prices) { 14 | | [] 15 | | [_] => None 16 | | prices => 17 | let result = 18 | prices 19 | |> List.sort((x, y) => - Float.compare(x, y)) 20 | |> List.filteri((index, _) => index mod 2 == 1) 21 | |> List.fold_left((+.), 0.0); 22 | Some(result); 23 | }; 24 | }; 25 | 26 | // Buy 1+ burger with 1+ of every topping, get half off 27 | let getHalfOff = (items: list(Item.t)) => { 28 | let meetsCondition = 29 | items 30 | |> List.exists( 31 | fun 32 | | Item.Burger({lettuce: true, tomatoes: true, onions, cheese, bacon}) 33 | when onions > 0 && cheese > 0 && bacon > 0 => 34 | true 35 | | Burger(_) 36 | | Sandwich(_) 37 | | Hotdog => false, 38 | ); 39 | 40 | switch (meetsCondition) { 41 | | false => None 42 | | true => 43 | let total = 44 | items 45 | |> ListLabels.fold_left(~init=0.0, ~f=(total, item) => 46 | total +. Item.toPrice(item) 47 | ); 48 | Some(total /. 2.0); 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /src/discounts-lists/Format.re: -------------------------------------------------------------------------------- 1 | let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; 2 | -------------------------------------------------------------------------------- /src/discounts-lists/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | let items: Order.t = [ 3 | Sandwich(Portabello), 4 | Sandwich(Unicorn), 5 | Sandwich(Ham), 6 | Sandwich(Turducken), 7 | Hotdog, 8 | Burger({ 9 | lettuce: true, 10 | tomatoes: true, 11 | onions: 3, 12 | cheese: 2, 13 | bacon: 6, 14 | }), 15 | Burger({ 16 | lettuce: false, 17 | tomatoes: false, 18 | onions: 0, 19 | cheese: 0, 20 | bacon: 0, 21 | }), 22 | Burger({ 23 | lettuce: true, 24 | tomatoes: false, 25 | onions: 1, 26 | cheese: 1, 27 | bacon: 1, 28 | }), 29 | Burger({ 30 | lettuce: false, 31 | tomatoes: false, 32 | onions: 1, 33 | cheese: 0, 34 | bacon: 0, 35 | }), 36 | Burger({ 37 | lettuce: false, 38 | tomatoes: false, 39 | onions: 0, 40 | cheese: 1, 41 | bacon: 0, 42 | }), 43 | ]; 44 | 45 | [@react.component] 46 | let make = () => 47 |
48 |

{React.string("Order confirmation")}

49 | 50 |
; 51 | }; 52 | 53 | let node = ReactDOM.querySelector("#root"); 54 | switch (node) { 55 | | None => 56 | Js.Console.error("Failed to start React: couldn't find the #root element") 57 | | Some(root) => 58 | let root = ReactDOM.Client.createRoot(root); 59 | ReactDOM.Client.render(root, ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/discounts-lists/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | 10 | let toEmoji = t => { 11 | let multiple = (emoji, count) => 12 | switch (count) { 13 | | 0 => "" 14 | | 1 => emoji 15 | | count => Printf.sprintf({js|%s×%d|js}, emoji, count) 16 | }; 17 | 18 | switch (t) { 19 | | {lettuce: false, onions: 0, cheese: 0, tomatoes: false, bacon: 0} => {js|🍔|js} 20 | | {lettuce, onions, cheese, tomatoes, bacon} => 21 | let toppingsCount = 22 | (lettuce ? 1 : 0) + (tomatoes ? 1 : 0) + onions + cheese + bacon; 23 | 24 | Printf.sprintf( 25 | {js|🍔%s{%s}|js}, 26 | toppingsCount > 12 ? {js|🥣|js} : "", 27 | [| 28 | lettuce ? {js|🥬|js} : "", 29 | tomatoes ? {js|🍅|js} : "", 30 | multiple({js|🧅|js}, onions), 31 | multiple({js|🧀|js}, cheese), 32 | multiple({js|🥓|js}, bacon), 33 | |] 34 | |> Js.Array.filter(~f=str => str != "") 35 | |> Js.Array.join(~sep=","), 36 | ); 37 | }; 38 | }; 39 | 40 | let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { 41 | let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; 42 | 43 | 15. // base cost 44 | +. toppingCost(onions, 0.2) 45 | +. toppingCost(cheese, 0.1) 46 | +. (tomatoes ? 0.05 : 0.0) 47 | +. toppingCost(bacon, 0.5); 48 | }; 49 | }; 50 | 51 | module Sandwich = { 52 | type t = 53 | | Portabello 54 | | Ham 55 | | Unicorn 56 | | Turducken; 57 | 58 | let toPrice = (~date: Js.Date.t, t) => { 59 | let day = date |> Js.Date.getDay |> int_of_float; 60 | 61 | switch (t) { 62 | | Portabello 63 | | Ham => 10. 64 | | Unicorn => 80. 65 | | Turducken when day == 2 => 10. 66 | | Turducken => 20. 67 | }; 68 | }; 69 | 70 | let toEmoji = t => 71 | Printf.sprintf( 72 | {js|🥪(%s)|js}, 73 | switch (t) { 74 | | Portabello => {js|🍄|js} 75 | | Ham => {js|🐷|js} 76 | | Unicorn => {js|🦄|js} 77 | | Turducken => {js|🦃🦆🐓|js} 78 | }, 79 | ); 80 | }; 81 | 82 | type t = 83 | | Sandwich(Sandwich.t) 84 | | Burger(Burger.t) 85 | | Hotdog; 86 | 87 | let toPrice = t => { 88 | switch (t) { 89 | | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date=Js.Date.make()) 90 | | Burger(burger) => Burger.toPrice(burger) 91 | | Hotdog => 5. 92 | }; 93 | }; 94 | 95 | let toEmoji = 96 | fun 97 | | Hotdog => {js|🌭|js} 98 | | Burger(burger) => Burger.toEmoji(burger) 99 | | Sandwich(sandwich) => Sandwich.toEmoji(sandwich); 100 | -------------------------------------------------------------------------------- /src/discounts-lists/ListSafe.re: -------------------------------------------------------------------------------- 1 | /** Return the nth element encased in Some; if it doesn't exist, return None */ 2 | let nth = (n, list) => n < 0 ? None : List.nth_opt(list, n); 3 | -------------------------------------------------------------------------------- /src/discounts-lists/Order.re: -------------------------------------------------------------------------------- 1 | type t = list(Item.t); 2 | 3 | module OrderItem = { 4 | [@mel.module "./order-item.module.css"] 5 | external css: Js.t({..}) = "default"; 6 | 7 | [@react.component] 8 | let make = (~item: Item.t) => 9 | 10 | {item |> Item.toEmoji |> React.string} 11 | {item |> Item.toPrice |> Format.currency} 12 | ; 13 | }; 14 | 15 | [@mel.module "./order.module.css"] external css: Js.t({..}) = "default"; 16 | 17 | [@react.component] 18 | let make = (~items: t) => { 19 | let total = 20 | items 21 | |> ListLabels.fold_left(~init=0., ~f=(acc, order) => 22 | acc +. Item.toPrice(order) 23 | ); 24 | 25 | 26 | 27 | {items 28 | |> List.mapi((index, item) => 29 | 30 | ) 31 | |> Stdlib.Array.of_list 32 | |> React.array} 33 | 34 | 35 | 36 | 37 | 38 |
{React.string("Total")} {total |> Format.currency}
; 39 | }; 40 | -------------------------------------------------------------------------------- /src/discounts-lists/SandwichTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | test("Item.Sandwich.toEmoji", () => { 4 | expect 5 | |> deepEqual( 6 | [|Portabello, Ham, Unicorn, Turducken|] 7 | |> Js.Array.map(~f=Item.Sandwich.toEmoji), 8 | [| 9 | {js|🥪(🍄)|js}, 10 | {js|🥪(🐷)|js}, 11 | {js|🥪(🦄)|js}, 12 | {js|🥪(🦃🦆🐓)|js}, 13 | |], 14 | ) 15 | }); 16 | 17 | test("Item.Sandwich.toPrice", () => { 18 | // 14 Feb 2024 is a Wednesday 19 | let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.); 20 | 21 | expect 22 | |> deepEqual( 23 | [|Portabello, Ham, Unicorn, Turducken|] 24 | |> Js.Array.map(~f=Item.Sandwich.toPrice(~date)), 25 | [|10., 10., 80., 20.|], 26 | ); 27 | }); 28 | 29 | test("Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays", () => { 30 | // Make an array of all dates in a single week; 1 Jan 2024 is a Monday 31 | let dates = 32 | [|1., 2., 3., 4., 5., 6., 7.|] 33 | |> Js.Array.map(~f=date => 34 | Js.Date.makeWithYMD(~year=2024., ~month=0., ~date) 35 | ); 36 | 37 | expect 38 | |> deepEqual( 39 | dates 40 | |> Js.Array.map(~f=date => Item.Sandwich.toPrice(Turducken, ~date)), 41 | [|20., 10., 20., 20., 20., 20., 20.|], 42 | ); 43 | }); 44 | -------------------------------------------------------------------------------- /src/discounts-lists/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems 7 | (es6 mjs)) 8 | (runtime_deps 9 | (glob_files *.css))) 10 | 11 | (cram 12 | (deps 13 | (alias melange))) 14 | -------------------------------------------------------------------------------- /src/discounts-lists/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/discounts-lists/order-item.module.css: -------------------------------------------------------------------------------- 1 | .item { 2 | border-top: 1px solid lightgray; 3 | } 4 | 5 | .emoji { 6 | font-size: 2em; 7 | } 8 | 9 | .price { 10 | text-align: right; 11 | } 12 | -------------------------------------------------------------------------------- /src/discounts-lists/order.module.css: -------------------------------------------------------------------------------- 1 | table.order { 2 | border-collapse: collapse; 3 | } 4 | 5 | table.order td { 6 | padding: 0.5em; 7 | } 8 | 9 | .total { 10 | border-top: 1px solid gray; 11 | font-weight: bold; 12 | text-align: right; 13 | } 14 | -------------------------------------------------------------------------------- /src/discounts-lists/tests.t: -------------------------------------------------------------------------------- 1 | Sandwich tests 2 | $ node ./output/src/discounts-lists/SandwichTests.mjs | sed '/duration_ms/d' 3 | TAP version 13 4 | # Subtest: Item.Sandwich.toEmoji 5 | ok 1 - Item.Sandwich.toEmoji 6 | --- 7 | ... 8 | # Subtest: Item.Sandwich.toPrice 9 | ok 2 - Item.Sandwich.toPrice 10 | --- 11 | ... 12 | # Subtest: Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 13 | ok 3 - Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 14 | --- 15 | ... 16 | 1..3 17 | # tests 3 18 | # suites 0 19 | # pass 3 20 | # fail 0 21 | # cancelled 0 22 | # skipped 0 23 | # todo 0 24 | 25 | Burger tests 26 | $ node ./output/src/discounts-lists/BurgerTests.mjs | sed '/duration_ms/d' 27 | TAP version 13 28 | # Subtest: A fully-loaded burger 29 | ok 1 - A fully-loaded burger 30 | --- 31 | ... 32 | # Subtest: Burger with 0 of onions, cheese, or bacon doesn't show those emoji 33 | ok 2 - Burger with 0 of onions, cheese, or bacon doesn't show those emoji 34 | --- 35 | ... 36 | # Subtest: Burger with 1 of onions, cheese, or bacon should show just the emoji without × 37 | ok 3 - Burger with 1 of onions, cheese, or bacon should show just the emoji without × 38 | --- 39 | ... 40 | # Subtest: Burger with 2 or more of onions, cheese, or bacon should show × 41 | ok 4 - Burger with 2 or more of onions, cheese, or bacon should show × 42 | --- 43 | ... 44 | # Subtest: Burger with more than 12 toppings should also show bowl emoji 45 | ok 5 - Burger with more than 12 toppings should also show bowl emoji 46 | --- 47 | ... 48 | 1..5 49 | # tests 5 50 | # suites 0 51 | # pass 5 52 | # fail 0 53 | # cancelled 0 54 | # skipped 0 55 | # todo 0 56 | 57 | Discount tests 58 | $ node ./output/src/discounts-lists/DiscountTests.mjs | sed '/duration_ms/d' 59 | TAP version 13 60 | # Subtest: 0 burgers, no discount 61 | ok 1 - 0 burgers, no discount 62 | --- 63 | ... 64 | # Subtest: 1 burger, no discount 65 | ok 2 - 1 burger, no discount 66 | --- 67 | ... 68 | # Subtest: 2 burgers of same price, discount 69 | ok 3 - 2 burgers of same price, discount 70 | --- 71 | ... 72 | # Subtest: 2 burgers of different price, discount of cheaper one 73 | ok 4 - 2 burgers of different price, discount of cheaper one 74 | --- 75 | ... 76 | # Subtest: 3 burgers of different price, return Some(15.15) 77 | ok 5 - 3 burgers of different price, return Some(15.15) 78 | --- 79 | ... 80 | # Subtest: 7 burgers, return Some(46.75) 81 | ok 6 - 7 burgers, return Some(46.75) 82 | --- 83 | ... 84 | # Subtest: No burger has 1+ of every topping, return None 85 | ok 7 - No burger has 1+ of every topping, return None 86 | --- 87 | ... 88 | # Subtest: One burger has 1+ of every topping, return Some 89 | ok 8 - One burger has 1+ of every topping, return Some 90 | --- 91 | ... 92 | 1..8 93 | # tests 8 94 | # suites 0 95 | # pass 8 96 | # fail 0 97 | # cancelled 0 98 | # skipped 0 99 | # todo 0 100 | -------------------------------------------------------------------------------- /src/numeric-types/Counter_Float.re: -------------------------------------------------------------------------------- 1 | [@react.component] 2 | let make = () => { 3 | let (counter, setCounter) = React.useState(() => 0.0); 4 | 5 |
12 | 15 | {counter |> Float.to_string |> React.string} 16 | 19 |
; 20 | }; 21 | -------------------------------------------------------------------------------- /src/numeric-types/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | [@react.component] 3 | let make = () => 4 |
5 |

{React.string("Counter using float")}

6 | 7 |
; 8 | }; 9 | 10 | let node = ReactDOM.querySelector("#root"); 11 | switch (node) { 12 | | None => 13 | Js.Console.error("Failed to start React: couldn't find the #root element") 14 | | Some(root) => 15 | let root = ReactDOM.Client.createRoot(root); 16 | ReactDOM.Client.render(root, ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/numeric-types/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /src/numeric-types/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/order-confirmation/Format.re: -------------------------------------------------------------------------------- 1 | let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; 2 | -------------------------------------------------------------------------------- /src/order-confirmation/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | let items: Order.t = [|Sandwich, Burger, Sandwich, Hotdog|]; 3 | 4 | [@react.component] 5 | let make = () => 6 |
7 |

{React.string("Order confirmation")}

8 | 9 |
; 10 | }; 11 | 12 | let node = ReactDOM.querySelector("#root"); 13 | switch (node) { 14 | | None => 15 | Js.Console.error("Failed to start React: couldn't find the #root element") 16 | | Some(root) => 17 | let root = ReactDOM.Client.createRoot(root); 18 | ReactDOM.Client.render(root, ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/order-confirmation/Item.re: -------------------------------------------------------------------------------- 1 | type t = 2 | | Sandwich 3 | | Burger 4 | | Hotdog; 5 | 6 | let toPrice = 7 | fun 8 | | Sandwich => 10. 9 | | Burger => 15. 10 | | Hotdog => 5.; 11 | 12 | let toEmoji = 13 | fun 14 | | Sandwich => {js|🥪|js} 15 | | Burger => {js|🍔|js} 16 | | Hotdog => {js|🌭|js}; 17 | -------------------------------------------------------------------------------- /src/order-confirmation/Order.re: -------------------------------------------------------------------------------- 1 | type t = array(Item.t); 2 | 3 | module OrderItem = { 4 | [@react.component] 5 | let make = (~item: Item.t) => 6 | 7 | {item |> Item.toEmoji |> React.string} 8 | {item |> Item.toPrice |> Format.currency} 9 | ; 10 | }; 11 | 12 | [@react.component] 13 | let make = (~items: t) => { 14 | let total = 15 | items 16 | |> Js.Array.reduce(~init=0., ~f=(acc, order) => 17 | acc +. Item.toPrice(order) 18 | ); 19 | 20 | 21 | 22 | {items 23 | |> Js.Array.mapi(~f=(item, index) => 24 | 25 | ) 26 | |> React.array} 27 | 28 | 29 | 30 | 31 | 32 |
{React.string("Total")} {total |> Format.currency}
; 33 | }; 34 | -------------------------------------------------------------------------------- /src/order-confirmation/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6)) 7 | -------------------------------------------------------------------------------- /src/order-confirmation/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/order-with-promo/Array.re: -------------------------------------------------------------------------------- 1 | // Safe array access function 2 | let get: (array('a), int) => option('a) = 3 | (array, index) => 4 | switch (index) { 5 | | index when index < 0 || index >= Js.Array.length(array) => None 6 | | index => Some(Stdlib.Array.get(array, index)) 7 | }; 8 | -------------------------------------------------------------------------------- /src/order-with-promo/BurgerTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | test("A fully-loaded burger", () => 4 | expect 5 | |> equal( 6 | Item.Burger.toEmoji({ 7 | lettuce: true, 8 | onions: 2, 9 | cheese: 3, 10 | tomatoes: true, 11 | bacon: 4, 12 | }), 13 | {js|🍔{🥬,🍅,🧅×2,🧀×3,🥓×4}|js}, 14 | ) 15 | ); 16 | 17 | test("Burger with 0 of onions, cheese, or bacon doesn't show those emoji", () => 18 | expect 19 | |> equal( 20 | Item.Burger.toEmoji({ 21 | lettuce: true, 22 | tomatoes: true, 23 | onions: 0, 24 | cheese: 0, 25 | bacon: 0, 26 | }), 27 | {js|🍔{🥬,🍅}|js}, 28 | ) 29 | ); 30 | 31 | test( 32 | "Burger with 1 of onions, cheese, or bacon should show just the emoji without ×", 33 | () => 34 | expect 35 | |> equal( 36 | Item.Burger.toEmoji({ 37 | lettuce: true, 38 | tomatoes: true, 39 | onions: 1, 40 | cheese: 1, 41 | bacon: 1, 42 | }), 43 | {js|🍔{🥬,🍅,🧅,🧀,🥓}|js}, 44 | ) 45 | ); 46 | 47 | test("Burger with 2 or more of onions, cheese, or bacon should show ×", () => 48 | expect 49 | |> equal( 50 | Item.Burger.toEmoji({ 51 | lettuce: true, 52 | tomatoes: true, 53 | onions: 2, 54 | cheese: 2, 55 | bacon: 2, 56 | }), 57 | {js|🍔{🥬,🍅,🧅×2,🧀×2,🥓×2}|js}, 58 | ) 59 | ); 60 | 61 | test("Burger with more than 12 toppings should also show bowl emoji", () => { 62 | expect 63 | |> equal( 64 | Item.Burger.toEmoji({ 65 | lettuce: true, 66 | tomatoes: true, 67 | onions: 4, 68 | cheese: 2, 69 | bacon: 5, 70 | }), 71 | {js|🍔🥣{🥬,🍅,🧅×4,🧀×2,🥓×5}|js}, 72 | ); 73 | 74 | expect 75 | |> equal( 76 | Item.Burger.toEmoji({ 77 | lettuce: true, 78 | tomatoes: true, 79 | onions: 4, 80 | cheese: 2, 81 | bacon: 4, 82 | }), 83 | {js|🍔{🥬,🍅,🧅×4,🧀×2,🥓×4}|js}, 84 | ); 85 | }); 86 | -------------------------------------------------------------------------------- /src/order-with-promo/DateInput.re: -------------------------------------------------------------------------------- 1 | let stringToDate = s => 2 | // add "T00:00" to make sure the date is in local time 3 | s ++ "T00:00" |> Js.Date.fromString; 4 | 5 | let dateToString = d => 6 | Printf.sprintf( 7 | "%4.0f-%02.0f-%02.0f", 8 | Js.Date.getFullYear(d), 9 | Js.Date.getMonth(d) +. 1., 10 | Js.Date.getDate(d), 11 | ); 12 | 13 | [@react.component] 14 | let make = (~date: Js.Date.t, ~onChange: Js.Date.t => unit) => { 15 | evt |> RR.getValueFromEvent |> stringToDate |> onChange} 20 | />; 21 | }; 22 | -------------------------------------------------------------------------------- /src/order-with-promo/Demo.re: -------------------------------------------------------------------------------- 1 | let datasets = { 2 | [ 3 | ( 4 | "No burgers", 5 | Item.[ 6 | Sandwich(Unicorn), 7 | Hotdog, 8 | Sandwich(Ham), 9 | Sandwich(Turducken), 10 | Hotdog, 11 | ], 12 | ), 13 | { 14 | let burger = 15 | Item.Burger.{ 16 | lettuce: false, 17 | tomatoes: false, 18 | onions: 0, 19 | cheese: 0, 20 | bacon: 0, 21 | }; 22 | ( 23 | "5 burgers", 24 | { 25 | [ 26 | Burger({ 27 | ...burger, 28 | tomatoes: true, 29 | }), 30 | Burger({ 31 | ...burger, 32 | lettuce: true, 33 | }), 34 | Burger({ 35 | ...burger, 36 | bacon: 2, 37 | }), 38 | Burger({ 39 | ...burger, 40 | cheese: 3, 41 | onions: 9, 42 | tomatoes: true, 43 | }), 44 | Burger({ 45 | ...burger, 46 | onions: 2, 47 | }), 48 | ]; 49 | }, 50 | ); 51 | }, 52 | ( 53 | "1 burger with at least one of every topping", 54 | [ 55 | Hotdog, 56 | Burger({ 57 | lettuce: true, 58 | tomatoes: true, 59 | onions: 1, 60 | cheese: 2, 61 | bacon: 3, 62 | }), 63 | Sandwich(Turducken), 64 | ], 65 | ), 66 | ( 67 | "All sandwiches", 68 | [ 69 | Sandwich(Ham), 70 | Hotdog, 71 | Sandwich(Portabello), 72 | Sandwich(Unicorn), 73 | Hotdog, 74 | Sandwich(Turducken), 75 | ], 76 | ), 77 | ]; 78 | }; 79 | 80 | module DateAndOrder = { 81 | [@react.component] 82 | let make = (~label: string, ~items: list(Item.t)) => { 83 | let (date, setDate) = 84 | RR.useStateValue(Js.Date.fromString("2024-05-28T00:00")); 85 | 86 |
87 |

{RR.s(label)}

88 | 89 | 90 |
; 91 | }; 92 | }; 93 | 94 | [@react.component] 95 | let make = () => { 96 |
97 |

{RR.s("Order Confirmation")}

98 | {datasets 99 | |> List.map(((label, items)) => ) 100 | |> RR.list} 101 |
; 102 | }; 103 | -------------------------------------------------------------------------------- /src/order-with-promo/Index.re: -------------------------------------------------------------------------------- 1 | let node = ReactDOM.querySelector("#root"); 2 | switch (node) { 3 | | None => 4 | Js.Console.error("Failed to start React: couldn't find the #root element") 5 | | Some(root) => 6 | let root = ReactDOM.Client.createRoot(root); 7 | ReactDOM.Client.render(root, ); 8 | }; 9 | -------------------------------------------------------------------------------- /src/order-with-promo/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | 10 | let toEmoji = t => { 11 | let multiple = (emoji, count) => 12 | switch (count) { 13 | | 0 => "" 14 | | 1 => emoji 15 | | count => Printf.sprintf({js|%s×%d|js}, emoji, count) 16 | }; 17 | 18 | switch (t) { 19 | | {lettuce: false, onions: 0, cheese: 0, tomatoes: false, bacon: 0} => {js|🍔|js} 20 | | {lettuce, onions, cheese, tomatoes, bacon} => 21 | let toppingsCount = 22 | (lettuce ? 1 : 0) + (tomatoes ? 1 : 0) + onions + cheese + bacon; 23 | 24 | Printf.sprintf( 25 | {js|🍔%s{%s}|js}, 26 | toppingsCount > 12 ? {js|🥣|js} : "", 27 | [| 28 | lettuce ? {js|🥬|js} : "", 29 | tomatoes ? {js|🍅|js} : "", 30 | multiple({js|🧅|js}, onions), 31 | multiple({js|🧀|js}, cheese), 32 | multiple({js|🥓|js}, bacon), 33 | |] 34 | |> Js.Array.filter(~f=str => str != "") 35 | |> Js.Array.join(~sep=","), 36 | ); 37 | }; 38 | }; 39 | 40 | let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { 41 | let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; 42 | 43 | 15. // base cost 44 | +. toppingCost(onions, 0.2) 45 | +. toppingCost(cheese, 0.1) 46 | +. (tomatoes ? 0.05 : 0.0) 47 | +. toppingCost(bacon, 0.5); 48 | }; 49 | }; 50 | 51 | module Sandwich = { 52 | type t = 53 | | Portabello 54 | | Ham 55 | | Unicorn 56 | | Turducken; 57 | 58 | let toPrice = (~date: Js.Date.t, t) => { 59 | let day = date |> Js.Date.getDay |> int_of_float; 60 | 61 | switch (t) { 62 | | Portabello 63 | | Ham => 10. 64 | | Unicorn => 80. 65 | | Turducken when day == 2 => 10. 66 | | Turducken => 20. 67 | }; 68 | }; 69 | 70 | let toEmoji = t => 71 | Printf.sprintf( 72 | {js|🥪(%s)|js}, 73 | switch (t) { 74 | | Portabello => {js|🍄|js} 75 | | Ham => {js|🐷|js} 76 | | Unicorn => {js|🦄|js} 77 | | Turducken => {js|🦃🦆🐓|js} 78 | }, 79 | ); 80 | }; 81 | 82 | type t = 83 | | Sandwich(Sandwich.t) 84 | | Burger(Burger.t) 85 | | Hotdog; 86 | 87 | let toPrice = (~date: Js.Date.t, t) => { 88 | switch (t) { 89 | | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date) 90 | | Burger(burger) => Burger.toPrice(burger) 91 | | Hotdog => 5. 92 | }; 93 | }; 94 | 95 | let toEmoji = 96 | fun 97 | | Hotdog => {js|🌭|js} 98 | | Burger(burger) => Burger.toEmoji(burger) 99 | | Sandwich(sandwich) => Sandwich.toEmoji(sandwich); 100 | -------------------------------------------------------------------------------- /src/order-with-promo/ListSafe.re: -------------------------------------------------------------------------------- 1 | /** Return the nth element encased in Some; if it doesn't exist, return None */ 2 | let nth = (n, list) => n < 0 ? None : List.nth_opt(list, n); 3 | -------------------------------------------------------------------------------- /src/order-with-promo/Order.re: -------------------------------------------------------------------------------- 1 | type t = list(Item.t); 2 | 3 | module OrderItem = { 4 | module Style = { 5 | let item = [%cx {|border-top: 1px solid lightgray;|}]; 6 | let emoji = [%cx {|font-size: 2em;|}]; 7 | let price = [%cx {|text-align: right;|}]; 8 | }; 9 | 10 | [@react.component] 11 | let make = (~item: Item.t, ~date: Js.Date.t) => 12 | 13 | {item |> Item.toEmoji |> RR.s} 14 | 15 | {item |> Item.toPrice(~date) |> RR.currency} 16 | 17 | ; 18 | }; 19 | 20 | module Style = { 21 | let order = [%cx 22 | {| 23 | border-collapse: collapse; 24 | 25 | td { 26 | padding: 0.5em; 27 | } 28 | |} 29 | ]; 30 | 31 | let total = [%cx 32 | {| 33 | border-top: 1px solid gray; 34 | font-weight: bold; 35 | text-align: right; 36 | |} 37 | ]; 38 | 39 | let promo = [%cx 40 | {| 41 | border-top: 1px solid gray; 42 | text-align: right; 43 | vertical-align: top; 44 | |} 45 | ]; 46 | }; 47 | 48 | [@react.component] 49 | let make = (~items: t, ~date: Js.Date.t) => { 50 | let (discount, setDiscount) = RR.useStateValue(0.0); 51 | 52 | let subtotal = 53 | items 54 | |> ListLabels.fold_left(~init=0., ~f=(acc, order) => 55 | acc +. Item.toPrice(order, ~date) 56 | ); 57 | 58 | 59 | 60 | {items 61 | |> List.mapi((index, item) => 62 | 63 | ) 64 | |> RR.list} 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 |
{RR.s("Subtotal")} {subtotal |> RR.currency}
{RR.s("Promo code")}
{RR.s("Total")} {subtotal -. discount |> RR.currency}
; 79 | }; 80 | -------------------------------------------------------------------------------- /src/order-with-promo/Promo.re: -------------------------------------------------------------------------------- 1 | module Style = { 2 | let form = [%cx {| 3 | display: flex; 4 | flex-direction: column; 5 | |}]; 6 | 7 | let input = [%cx 8 | {| 9 | font-family: monospace; 10 | text-transform: uppercase; 11 | |} 12 | ]; 13 | 14 | let codeError = [%cx {|color: red|}]; 15 | 16 | let discountError = [%cx {|color: purple|}]; 17 | }; 18 | 19 | type discount('a) = 20 | | CodeError(Discount.error) 21 | | Discount(float) 22 | | DiscountError([> ] as 'a) 23 | | NoSubmittedCode; 24 | 25 | [@react.component] 26 | let make = (~items: list(Item.t), ~date: Js.Date.t, ~onApply: float => unit) => { 27 | let (code, setCode) = RR.useStateValue(""); 28 | let (submittedCode, setSubmittedCode) = RR.useStateValue(None); 29 | 30 | let getDiscount = 31 | fun 32 | | None => `NoSubmittedCode 33 | | Some(code) => 34 | switch (Discount.getDiscountFunction(code, date)) { 35 | | Error(error) => `CodeError(error) 36 | | Ok(discountFunc) => 37 | switch (discountFunc(items)) { 38 | | Error(error) => `DiscountError(error) 39 | | Ok(value) => `Discount(value) 40 | } 41 | }; 42 | 43 |
{ 46 | evt |> React.Event.Form.preventDefault; 47 | let newSubmittedCode = Some(code); 48 | setSubmittedCode(newSubmittedCode); 49 | switch (getDiscount(newSubmittedCode)) { 50 | | `NoSubmittedCode 51 | | `CodeError(_) 52 | | `DiscountError(_) => () 53 | | `Discount(value) => onApply(value) 54 | }; 55 | }}> 56 | {evt |> RR.getValueFromEvent |> setCode}} 60 | /> 61 | {switch (getDiscount(submittedCode)) { 62 | | `NoSubmittedCode => React.null 63 | | `Discount(discount) => discount |> Float.neg |> RR.currency 64 | | `CodeError(error) => 65 |
66 | {let errorType = 67 | switch (error) { 68 | | Discount.InvalidCode => "Invalid" 69 | | ExpiredCode => "Expired" 70 | }; 71 | {j|$errorType promo code|j} |> RR.s} 72 |
73 | | `DiscountError(code) => 74 | let buyWhat = 75 | switch (code) { 76 | | `NeedOneBurger => "at least 1 more burger" 77 | | `NeedTwoBurgers => "at least 2 burgers" 78 | | `NeedMegaBurger => "a burger with every topping" 79 | | `MissingSandwichTypes(missing) => 80 | (missing |> Stdlib.Array.of_list |> Js.Array.join(~sep=", ")) 81 | ++ " sandwiches" 82 | }; 83 |
84 | {RR.s({j|Buy $buyWhat to enjoy this promotion|j})} 85 |
; 86 | }} 87 |
; 88 | }; 89 | -------------------------------------------------------------------------------- /src/order-with-promo/RR.re: -------------------------------------------------------------------------------- 1 | /** Get string value from the given event's target */ 2 | let getValueFromEvent = (evt): string => React.Event.Form.target(evt)##value; 3 | 4 | /** Alias for [React.string] */ 5 | let s = React.string; 6 | 7 | /** Render a list of [React.element]s */ 8 | let list = list => list |> Stdlib.Array.of_list |> React.array; 9 | 10 | /** Render a float as currency */ 11 | let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; 12 | 13 | /** Like [React.useState] but doesn't use callback functions */ 14 | let useStateValue = initial => 15 | React.useReducer((_state, newState) => newState, initial); 16 | 17 | /** Helper for [React.useEffect1] */ 18 | let useEffect1 = (func, dep) => React.useEffect1(func, [|dep|]); 19 | -------------------------------------------------------------------------------- /src/order-with-promo/SandwichTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | test("Item.Sandwich.toEmoji", () => { 4 | expect 5 | |> deepEqual( 6 | [|Portabello, Ham, Unicorn, Turducken|] 7 | |> Js.Array.map(~f=Item.Sandwich.toEmoji), 8 | [| 9 | {js|🥪(🍄)|js}, 10 | {js|🥪(🐷)|js}, 11 | {js|🥪(🦄)|js}, 12 | {js|🥪(🦃🦆🐓)|js}, 13 | |], 14 | ) 15 | }); 16 | 17 | test("Item.Sandwich.toPrice", () => { 18 | // 14 Feb 2024 is a Wednesday 19 | let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.); 20 | 21 | expect 22 | |> deepEqual( 23 | [|Portabello, Ham, Unicorn, Turducken|] 24 | |> Js.Array.map(~f=Item.Sandwich.toPrice(~date)), 25 | [|10., 10., 80., 20.|], 26 | ); 27 | }); 28 | 29 | test("Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays", () => { 30 | // Make an array of all dates in a single week; 1 Jan 2024 is a Monday 31 | let dates = 32 | [|1., 2., 3., 4., 5., 6., 7.|] 33 | |> Js.Array.map(~f=date => 34 | Js.Date.makeWithYMD(~year=2024., ~month=0., ~date) 35 | ); 36 | 37 | expect 38 | |> deepEqual( 39 | dates 40 | |> Js.Array.map(~f=date => Item.Sandwich.toPrice(Turducken, ~date)), 41 | [|20., 10., 20., 20., 20., 20., 20.|], 42 | ); 43 | }); 44 | -------------------------------------------------------------------------------- /src/order-with-promo/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest styled-ppx.melange) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx styled-ppx)) 6 | (module_systems 7 | (es6 mjs))) 8 | 9 | (cram 10 | (deps 11 | (alias melange))) 12 | -------------------------------------------------------------------------------- /src/order-with-promo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/promo-codes/Array.re: -------------------------------------------------------------------------------- 1 | // Safe array access function 2 | let get: (array('a), int) => option('a) = 3 | (array, index) => 4 | switch (index) { 5 | | index when index < 0 || index >= Js.Array.length(array) => None 6 | | index => Some(Stdlib.Array.get(array, index)) 7 | }; 8 | -------------------------------------------------------------------------------- /src/promo-codes/BurgerTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | test("A fully-loaded burger", () => 4 | expect 5 | |> equal( 6 | Item.Burger.toEmoji({ 7 | lettuce: true, 8 | onions: 2, 9 | cheese: 3, 10 | tomatoes: true, 11 | bacon: 4, 12 | }), 13 | {js|🍔{🥬,🍅,🧅×2,🧀×3,🥓×4}|js}, 14 | ) 15 | ); 16 | 17 | test("Burger with 0 of onions, cheese, or bacon doesn't show those emoji", () => 18 | expect 19 | |> equal( 20 | Item.Burger.toEmoji({ 21 | lettuce: true, 22 | tomatoes: true, 23 | onions: 0, 24 | cheese: 0, 25 | bacon: 0, 26 | }), 27 | {js|🍔{🥬,🍅}|js}, 28 | ) 29 | ); 30 | 31 | test( 32 | "Burger with 1 of onions, cheese, or bacon should show just the emoji without ×", 33 | () => 34 | expect 35 | |> equal( 36 | Item.Burger.toEmoji({ 37 | lettuce: true, 38 | tomatoes: true, 39 | onions: 1, 40 | cheese: 1, 41 | bacon: 1, 42 | }), 43 | {js|🍔{🥬,🍅,🧅,🧀,🥓}|js}, 44 | ) 45 | ); 46 | 47 | test("Burger with 2 or more of onions, cheese, or bacon should show ×", () => 48 | expect 49 | |> equal( 50 | Item.Burger.toEmoji({ 51 | lettuce: true, 52 | tomatoes: true, 53 | onions: 2, 54 | cheese: 2, 55 | bacon: 2, 56 | }), 57 | {js|🍔{🥬,🍅,🧅×2,🧀×2,🥓×2}|js}, 58 | ) 59 | ); 60 | 61 | test("Burger with more than 12 toppings should also show bowl emoji", () => { 62 | expect 63 | |> equal( 64 | Item.Burger.toEmoji({ 65 | lettuce: true, 66 | tomatoes: true, 67 | onions: 4, 68 | cheese: 2, 69 | bacon: 5, 70 | }), 71 | {js|🍔🥣{🥬,🍅,🧅×4,🧀×2,🥓×5}|js}, 72 | ); 73 | 74 | expect 75 | |> equal( 76 | Item.Burger.toEmoji({ 77 | lettuce: true, 78 | tomatoes: true, 79 | onions: 4, 80 | cheese: 2, 81 | bacon: 4, 82 | }), 83 | {js|🍔{🥬,🍅,🧅×4,🧀×2,🥓×4}|js}, 84 | ); 85 | }); 86 | -------------------------------------------------------------------------------- /src/promo-codes/Discount.re: -------------------------------------------------------------------------------- 1 | type error = 2 | | InvalidCode 3 | | ExpiredCode; 4 | 5 | /** Buy n burgers, get n/2 burgers free */ 6 | let getFreeBurgers = (items: list(Item.t)) => { 7 | let prices = 8 | items 9 | |> List.filter_map(item => 10 | switch (item) { 11 | | Item.Burger(burger) => Some(Item.Burger.toPrice(burger)) 12 | | Sandwich(_) 13 | | Hotdog => None 14 | } 15 | ); 16 | 17 | switch (prices) { 18 | | [] => Error(`NeedTwoBurgers) 19 | | [_] => Error(`NeedOneBurger) 20 | | prices => 21 | let result = 22 | prices 23 | |> List.sort((x, y) => - Float.compare(x, y)) 24 | |> List.filteri((index, _) => index mod 2 == 1) 25 | |> List.fold_left((+.), 0.0); 26 | Ok(result); 27 | }; 28 | }; 29 | 30 | /** Buy 1+ burger with 1+ of every topping, get half off */ 31 | let getHalfOff = (items: list(Item.t)) => { 32 | let meetsCondition = 33 | items 34 | |> List.exists( 35 | fun 36 | | Item.Burger({lettuce: true, tomatoes: true, onions, cheese, bacon}) 37 | when onions > 0 && cheese > 0 && bacon > 0 => 38 | true 39 | | Burger(_) 40 | | Sandwich(_) 41 | | Hotdog => false, 42 | ); 43 | 44 | switch (meetsCondition) { 45 | | false => Error(`NeedMegaBurger) 46 | | true => 47 | let total = 48 | items 49 | |> ListLabels.fold_left(~init=0.0, ~f=(total, item) => 50 | total +. Item.toPrice(item) 51 | ); 52 | Ok(total /. 2.0); 53 | }; 54 | }; 55 | 56 | type sandwichTracker = { 57 | portabello: bool, 58 | ham: bool, 59 | unicorn: bool, 60 | turducken: bool, 61 | }; 62 | 63 | /** Buy 1+ of every type of sandwich, get half off */ 64 | let getSandwichHalfOff = (items: list(Item.t)) => { 65 | let tracker = 66 | items 67 | |> List.filter_map( 68 | fun 69 | | Item.Sandwich(sandwich) => Some(sandwich) 70 | | Burger(_) 71 | | Hotdog => None, 72 | ) 73 | |> ListLabels.fold_left( 74 | ~init={ 75 | portabello: false, 76 | ham: false, 77 | unicorn: false, 78 | turducken: false, 79 | }, 80 | ~f=(tracker, sandwich: Item.Sandwich.t) => 81 | switch (sandwich) { 82 | | Portabello => { 83 | ...tracker, 84 | portabello: true, 85 | } 86 | | Ham => { 87 | ...tracker, 88 | ham: true, 89 | } 90 | | Unicorn => { 91 | ...tracker, 92 | unicorn: true, 93 | } 94 | | Turducken => { 95 | ...tracker, 96 | turducken: true, 97 | } 98 | } 99 | ); 100 | 101 | switch (tracker) { 102 | | {portabello: true, ham: true, unicorn: true, turducken: true} => 103 | let total = 104 | items 105 | |> ListLabels.fold_left(~init=0.0, ~f=(total, item) => 106 | total +. Item.toPrice(item) 107 | ); 108 | Ok(total /. 2.0); 109 | | _ => Error(`MissingSandwichTypes) 110 | }; 111 | }; 112 | 113 | let getDiscountFunction = (code, date) => { 114 | let month = date |> Js.Date.getMonth; 115 | let dayOfMonth = date |> Js.Date.getDate; 116 | 117 | switch (code |> Js.String.toUpperCase) { 118 | | "FREE" when month == 4.0 => Ok(getFreeBurgers) 119 | | "HALF" when month == 4.0 && dayOfMonth == 28.0 => Ok(getHalfOff) 120 | | "HALF" when month == 10.0 && dayOfMonth == 3.0 => Ok(getSandwichHalfOff) 121 | | "FREE" 122 | | "HALF" => Error(ExpiredCode) 123 | | _ => Error(InvalidCode) 124 | }; 125 | }; 126 | -------------------------------------------------------------------------------- /src/promo-codes/Format.re: -------------------------------------------------------------------------------- 1 | let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; 2 | -------------------------------------------------------------------------------- /src/promo-codes/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | let items: Order.t = [ 3 | Sandwich(Portabello), 4 | Sandwich(Unicorn), 5 | Sandwich(Ham), 6 | Sandwich(Turducken), 7 | Hotdog, 8 | Burger({ 9 | lettuce: true, 10 | tomatoes: true, 11 | onions: 3, 12 | cheese: 2, 13 | bacon: 6, 14 | }), 15 | Burger({ 16 | lettuce: false, 17 | tomatoes: false, 18 | onions: 0, 19 | cheese: 0, 20 | bacon: 0, 21 | }), 22 | Burger({ 23 | lettuce: true, 24 | tomatoes: false, 25 | onions: 1, 26 | cheese: 1, 27 | bacon: 1, 28 | }), 29 | Burger({ 30 | lettuce: false, 31 | tomatoes: false, 32 | onions: 1, 33 | cheese: 0, 34 | bacon: 0, 35 | }), 36 | Burger({ 37 | lettuce: false, 38 | tomatoes: false, 39 | onions: 0, 40 | cheese: 1, 41 | bacon: 0, 42 | }), 43 | ]; 44 | 45 | [@react.component] 46 | let make = () => 47 |
48 |

{React.string("Order confirmation")}

49 | 50 |
; 51 | }; 52 | 53 | let node = ReactDOM.querySelector("#root"); 54 | switch (node) { 55 | | None => 56 | Js.Console.error("Failed to start React: couldn't find the #root element") 57 | | Some(root) => 58 | let root = ReactDOM.Client.createRoot(root); 59 | ReactDOM.Client.render(root, ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/promo-codes/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | 10 | let toEmoji = t => { 11 | let multiple = (emoji, count) => 12 | switch (count) { 13 | | 0 => "" 14 | | 1 => emoji 15 | | count => Printf.sprintf({js|%s×%d|js}, emoji, count) 16 | }; 17 | 18 | switch (t) { 19 | | {lettuce: false, onions: 0, cheese: 0, tomatoes: false, bacon: 0} => {js|🍔|js} 20 | | {lettuce, onions, cheese, tomatoes, bacon} => 21 | let toppingsCount = 22 | (lettuce ? 1 : 0) + (tomatoes ? 1 : 0) + onions + cheese + bacon; 23 | 24 | Printf.sprintf( 25 | {js|🍔%s{%s}|js}, 26 | toppingsCount > 12 ? {js|🥣|js} : "", 27 | [| 28 | lettuce ? {js|🥬|js} : "", 29 | tomatoes ? {js|🍅|js} : "", 30 | multiple({js|🧅|js}, onions), 31 | multiple({js|🧀|js}, cheese), 32 | multiple({js|🥓|js}, bacon), 33 | |] 34 | |> Js.Array.filter(~f=str => str != "") 35 | |> Js.Array.join(~sep=","), 36 | ); 37 | }; 38 | }; 39 | 40 | let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { 41 | let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; 42 | 43 | 15. // base cost 44 | +. toppingCost(onions, 0.2) 45 | +. toppingCost(cheese, 0.1) 46 | +. (tomatoes ? 0.05 : 0.0) 47 | +. toppingCost(bacon, 0.5); 48 | }; 49 | }; 50 | 51 | module Sandwich = { 52 | type t = 53 | | Portabello 54 | | Ham 55 | | Unicorn 56 | | Turducken; 57 | 58 | let toPrice = (~date: Js.Date.t, t) => { 59 | let day = date |> Js.Date.getDay |> int_of_float; 60 | 61 | switch (t) { 62 | | Portabello 63 | | Ham => 10. 64 | | Unicorn => 80. 65 | | Turducken when day == 2 => 10. 66 | | Turducken => 20. 67 | }; 68 | }; 69 | 70 | let toEmoji = t => 71 | Printf.sprintf( 72 | {js|🥪(%s)|js}, 73 | switch (t) { 74 | | Portabello => {js|🍄|js} 75 | | Ham => {js|🐷|js} 76 | | Unicorn => {js|🦄|js} 77 | | Turducken => {js|🦃🦆🐓|js} 78 | }, 79 | ); 80 | }; 81 | 82 | type t = 83 | | Sandwich(Sandwich.t) 84 | | Burger(Burger.t) 85 | | Hotdog; 86 | 87 | let toPrice = t => { 88 | switch (t) { 89 | | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date=Js.Date.make()) 90 | | Burger(burger) => Burger.toPrice(burger) 91 | | Hotdog => 5. 92 | }; 93 | }; 94 | 95 | let toEmoji = 96 | fun 97 | | Hotdog => {js|🌭|js} 98 | | Burger(burger) => Burger.toEmoji(burger) 99 | | Sandwich(sandwich) => Sandwich.toEmoji(sandwich); 100 | -------------------------------------------------------------------------------- /src/promo-codes/ListSafe.re: -------------------------------------------------------------------------------- 1 | /** Return the nth element encased in Some; if it doesn't exist, return None */ 2 | let nth = (n, list) => n < 0 ? None : List.nth_opt(list, n); 3 | -------------------------------------------------------------------------------- /src/promo-codes/Order.re: -------------------------------------------------------------------------------- 1 | type t = list(Item.t); 2 | 3 | module OrderItem = { 4 | [@mel.module "./order-item.module.css"] 5 | external css: Js.t({..}) = "default"; 6 | 7 | [@react.component] 8 | let make = (~item: Item.t) => 9 | 10 | {item |> Item.toEmoji |> React.string} 11 | {item |> Item.toPrice |> Format.currency} 12 | ; 13 | }; 14 | 15 | [@mel.module "./order.module.css"] external css: Js.t({..}) = "default"; 16 | 17 | [@react.component] 18 | let make = (~items: t) => { 19 | let total = 20 | items 21 | |> ListLabels.fold_left(~init=0., ~f=(acc, order) => 22 | acc +. Item.toPrice(order) 23 | ); 24 | 25 | 26 | 27 | {items 28 | |> List.mapi((index, item) => 29 | 30 | ) 31 | |> Stdlib.Array.of_list 32 | |> React.array} 33 | 34 | 35 | 36 | 37 | 38 |
{React.string("Total")} {total |> Format.currency}
; 39 | }; 40 | -------------------------------------------------------------------------------- /src/promo-codes/SandwichTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | test("Item.Sandwich.toEmoji", () => { 4 | expect 5 | |> deepEqual( 6 | [|Portabello, Ham, Unicorn, Turducken|] 7 | |> Js.Array.map(~f=Item.Sandwich.toEmoji), 8 | [| 9 | {js|🥪(🍄)|js}, 10 | {js|🥪(🐷)|js}, 11 | {js|🥪(🦄)|js}, 12 | {js|🥪(🦃🦆🐓)|js}, 13 | |], 14 | ) 15 | }); 16 | 17 | test("Item.Sandwich.toPrice", () => { 18 | // 14 Feb 2024 is a Wednesday 19 | let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.); 20 | 21 | expect 22 | |> deepEqual( 23 | [|Portabello, Ham, Unicorn, Turducken|] 24 | |> Js.Array.map(~f=Item.Sandwich.toPrice(~date)), 25 | [|10., 10., 80., 20.|], 26 | ); 27 | }); 28 | 29 | test("Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays", () => { 30 | // Make an array of all dates in a single week; 1 Jan 2024 is a Monday 31 | let dates = 32 | [|1., 2., 3., 4., 5., 6., 7.|] 33 | |> Js.Array.map(~f=date => 34 | Js.Date.makeWithYMD(~year=2024., ~month=0., ~date) 35 | ); 36 | 37 | expect 38 | |> deepEqual( 39 | dates 40 | |> Js.Array.map(~f=date => Item.Sandwich.toPrice(Turducken, ~date)), 41 | [|20., 10., 20., 20., 20., 20., 20.|], 42 | ); 43 | }); 44 | -------------------------------------------------------------------------------- /src/promo-codes/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems 7 | (es6 mjs)) 8 | (runtime_deps 9 | (glob_files *.css))) 10 | 11 | (cram 12 | (deps 13 | (alias melange))) 14 | -------------------------------------------------------------------------------- /src/promo-codes/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/promo-codes/order-item.module.css: -------------------------------------------------------------------------------- 1 | .item { 2 | border-top: 1px solid lightgray; 3 | } 4 | 5 | .emoji { 6 | font-size: 2em; 7 | } 8 | 9 | .price { 10 | text-align: right; 11 | } 12 | -------------------------------------------------------------------------------- /src/promo-codes/order.module.css: -------------------------------------------------------------------------------- 1 | table.order { 2 | border-collapse: collapse; 3 | } 4 | 5 | table.order td { 6 | padding: 0.5em; 7 | } 8 | 9 | .total { 10 | border-top: 1px solid gray; 11 | font-weight: bold; 12 | text-align: right; 13 | } 14 | -------------------------------------------------------------------------------- /src/promo-codes/tests.t: -------------------------------------------------------------------------------- 1 | Sandwich tests 2 | $ node ./output/src/promo-codes/SandwichTests.mjs | sed '/duration_ms/d' 3 | TAP version 13 4 | # Subtest: Item.Sandwich.toEmoji 5 | ok 1 - Item.Sandwich.toEmoji 6 | --- 7 | ... 8 | # Subtest: Item.Sandwich.toPrice 9 | ok 2 - Item.Sandwich.toPrice 10 | --- 11 | ... 12 | # Subtest: Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 13 | ok 3 - Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 14 | --- 15 | ... 16 | 1..3 17 | # tests 3 18 | # suites 0 19 | # pass 3 20 | # fail 0 21 | # cancelled 0 22 | # skipped 0 23 | # todo 0 24 | 25 | Burger tests 26 | $ node ./output/src/promo-codes/BurgerTests.mjs | sed '/duration_ms/d' 27 | TAP version 13 28 | # Subtest: A fully-loaded burger 29 | ok 1 - A fully-loaded burger 30 | --- 31 | ... 32 | # Subtest: Burger with 0 of onions, cheese, or bacon doesn't show those emoji 33 | ok 2 - Burger with 0 of onions, cheese, or bacon doesn't show those emoji 34 | --- 35 | ... 36 | # Subtest: Burger with 1 of onions, cheese, or bacon should show just the emoji without × 37 | ok 3 - Burger with 1 of onions, cheese, or bacon should show just the emoji without × 38 | --- 39 | ... 40 | # Subtest: Burger with 2 or more of onions, cheese, or bacon should show × 41 | ok 4 - Burger with 2 or more of onions, cheese, or bacon should show × 42 | --- 43 | ... 44 | # Subtest: Burger with more than 12 toppings should also show bowl emoji 45 | ok 5 - Burger with more than 12 toppings should also show bowl emoji 46 | --- 47 | ... 48 | 1..5 49 | # tests 5 50 | # suites 0 51 | # pass 5 52 | # fail 0 53 | # cancelled 0 54 | # skipped 0 55 | # todo 0 56 | 57 | Discount tests 58 | $ node ./output/src/promo-codes/DiscountTests.mjs | sed '/duration_ms/d' 59 | TAP version 13 60 | # Subtest: 0 burgers, no discount 61 | ok 1 - 0 burgers, no discount 62 | --- 63 | ... 64 | # Subtest: 1 burger, no discount 65 | ok 2 - 1 burger, no discount 66 | --- 67 | ... 68 | # Subtest: 2 burgers of same price, discount 69 | ok 3 - 2 burgers of same price, discount 70 | --- 71 | ... 72 | # Subtest: 2 burgers of different price, discount of cheaper one 73 | ok 4 - 2 burgers of different price, discount of cheaper one 74 | --- 75 | ... 76 | # Subtest: 3 burgers of different price, return Ok(15.15) 77 | ok 5 - 3 burgers of different price, return Ok(15.15) 78 | --- 79 | ... 80 | # Subtest: 7 burgers, return Ok(46.75) 81 | ok 6 - 7 burgers, return Ok(46.75) 82 | --- 83 | ... 84 | # Subtest: No burger has 1+ of every topping, return Error(`NeedMegaBurger) 85 | ok 7 - No burger has 1+ of every topping, return Error(`NeedMegaBurger) 86 | --- 87 | ... 88 | # Subtest: One burger has 1+ of every topping, return Ok 89 | ok 8 - One burger has 1+ of every topping, return Ok 90 | --- 91 | ... 92 | # Subtest: Not all sandwiches, return Error 93 | ok 9 - Not all sandwiches, return Error 94 | --- 95 | ... 96 | # Subtest: All sandwiches, return Ok 97 | ok 10 - All sandwiches, return Ok 98 | --- 99 | ... 100 | # Subtest: Invalid promo code return Error 101 | ok 11 - Invalid promo code return Error 102 | --- 103 | ... 104 | # Subtest: FREE promo code works in May but not other months 105 | ok 12 - FREE promo code works in May but not other months 106 | --- 107 | ... 108 | # Subtest: HALF promo code returns getHalfOff on May 28 but not other days of May 109 | ok 13 - HALF promo code returns getHalfOff on May 28 but not other days of May 110 | --- 111 | ... 112 | # Subtest: HALF promo code returns getSandwichHalfOff on Nov 3 but not other days of Nov 113 | ok 14 - HALF promo code returns getSandwichHalfOff on Nov 3 but not other days of Nov 114 | --- 115 | ... 116 | 1..14 117 | # tests 14 118 | # suites 0 119 | # pass 14 120 | # fail 0 121 | # cancelled 0 122 | # skipped 0 123 | # todo 0 124 | -------------------------------------------------------------------------------- /src/promo-component/Array.re: -------------------------------------------------------------------------------- 1 | // Safe array access function 2 | let get: (array('a), int) => option('a) = 3 | (array, index) => 4 | switch (index) { 5 | | index when index < 0 || index >= Js.Array.length(array) => None 6 | | index => Some(Stdlib.Array.get(array, index)) 7 | }; 8 | -------------------------------------------------------------------------------- /src/promo-component/BurgerTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | test("A fully-loaded burger", () => 4 | expect 5 | |> equal( 6 | Item.Burger.toEmoji({ 7 | lettuce: true, 8 | onions: 2, 9 | cheese: 3, 10 | tomatoes: true, 11 | bacon: 4, 12 | }), 13 | {js|🍔{🥬,🍅,🧅×2,🧀×3,🥓×4}|js}, 14 | ) 15 | ); 16 | 17 | test("Burger with 0 of onions, cheese, or bacon doesn't show those emoji", () => 18 | expect 19 | |> equal( 20 | Item.Burger.toEmoji({ 21 | lettuce: true, 22 | tomatoes: true, 23 | onions: 0, 24 | cheese: 0, 25 | bacon: 0, 26 | }), 27 | {js|🍔{🥬,🍅}|js}, 28 | ) 29 | ); 30 | 31 | test( 32 | "Burger with 1 of onions, cheese, or bacon should show just the emoji without ×", 33 | () => 34 | expect 35 | |> equal( 36 | Item.Burger.toEmoji({ 37 | lettuce: true, 38 | tomatoes: true, 39 | onions: 1, 40 | cheese: 1, 41 | bacon: 1, 42 | }), 43 | {js|🍔{🥬,🍅,🧅,🧀,🥓}|js}, 44 | ) 45 | ); 46 | 47 | test("Burger with 2 or more of onions, cheese, or bacon should show ×", () => 48 | expect 49 | |> equal( 50 | Item.Burger.toEmoji({ 51 | lettuce: true, 52 | tomatoes: true, 53 | onions: 2, 54 | cheese: 2, 55 | bacon: 2, 56 | }), 57 | {js|🍔{🥬,🍅,🧅×2,🧀×2,🥓×2}|js}, 58 | ) 59 | ); 60 | 61 | test("Burger with more than 12 toppings should also show bowl emoji", () => { 62 | expect 63 | |> equal( 64 | Item.Burger.toEmoji({ 65 | lettuce: true, 66 | tomatoes: true, 67 | onions: 4, 68 | cheese: 2, 69 | bacon: 5, 70 | }), 71 | {js|🍔🥣{🥬,🍅,🧅×4,🧀×2,🥓×5}|js}, 72 | ); 73 | 74 | expect 75 | |> equal( 76 | Item.Burger.toEmoji({ 77 | lettuce: true, 78 | tomatoes: true, 79 | onions: 4, 80 | cheese: 2, 81 | bacon: 4, 82 | }), 83 | {js|🍔{🥬,🍅,🧅×4,🧀×2,🥓×4}|js}, 84 | ); 85 | }); 86 | -------------------------------------------------------------------------------- /src/promo-component/Discount.re: -------------------------------------------------------------------------------- 1 | type error = 2 | | InvalidCode 3 | | ExpiredCode; 4 | 5 | /** Buy n burgers, get n/2 burgers free */ 6 | let getFreeBurgers = (items: list(Item.t)) => { 7 | let prices = 8 | items 9 | |> List.filter_map(item => 10 | switch (item) { 11 | | Item.Burger(burger) => Some(Item.Burger.toPrice(burger)) 12 | | Sandwich(_) 13 | | Hotdog => None 14 | } 15 | ); 16 | 17 | switch (prices) { 18 | | [] => Error(`NeedTwoBurgers) 19 | | [_] => Error(`NeedOneBurger) 20 | | prices => 21 | let result = 22 | prices 23 | |> List.sort((x, y) => - Float.compare(x, y)) 24 | |> List.filteri((index, _) => index mod 2 == 1) 25 | |> List.fold_left((+.), 0.0); 26 | Ok(result); 27 | }; 28 | }; 29 | 30 | /** Buy 1+ burger with 1+ of every topping, get half off */ 31 | let getHalfOff = (items: list(Item.t), ~date: Js.Date.t) => { 32 | let meetsCondition = 33 | items 34 | |> List.exists( 35 | fun 36 | | Item.Burger({lettuce: true, tomatoes: true, onions, cheese, bacon}) 37 | when onions > 0 && cheese > 0 && bacon > 0 => 38 | true 39 | | Burger(_) 40 | | Sandwich(_) 41 | | Hotdog => false, 42 | ); 43 | 44 | switch (meetsCondition) { 45 | | false => Error(`NeedMegaBurger) 46 | | true => 47 | let total = 48 | items 49 | |> ListLabels.fold_left(~init=0.0, ~f=(total, item) => 50 | total +. Item.toPrice(item, ~date) 51 | ); 52 | Ok(total /. 2.0); 53 | }; 54 | }; 55 | 56 | type sandwichTracker = { 57 | portabello: bool, 58 | ham: bool, 59 | unicorn: bool, 60 | turducken: bool, 61 | }; 62 | 63 | /** Buy 1+ of every type of sandwich, get half off */ 64 | let getSandwichHalfOff = (items: list(Item.t), ~date: Js.Date.t) => { 65 | let tracker = 66 | items 67 | |> List.filter_map( 68 | fun 69 | | Item.Sandwich(sandwich) => Some(sandwich) 70 | | Burger(_) 71 | | Hotdog => None, 72 | ) 73 | |> ListLabels.fold_left( 74 | ~init={ 75 | portabello: false, 76 | ham: false, 77 | unicorn: false, 78 | turducken: false, 79 | }, 80 | ~f=(tracker, sandwich: Item.Sandwich.t) => 81 | switch (sandwich) { 82 | | Portabello => { 83 | ...tracker, 84 | portabello: true, 85 | } 86 | | Ham => { 87 | ...tracker, 88 | ham: true, 89 | } 90 | | Unicorn => { 91 | ...tracker, 92 | unicorn: true, 93 | } 94 | | Turducken => { 95 | ...tracker, 96 | turducken: true, 97 | } 98 | } 99 | ); 100 | 101 | switch (tracker) { 102 | | {portabello: true, ham: true, unicorn: true, turducken: true} => 103 | let total = 104 | items 105 | |> ListLabels.fold_left(~init=0.0, ~f=(total, item) => 106 | total +. Item.toPrice(item, ~date) 107 | ); 108 | Ok(total /. 2.0); 109 | | _ => Error(`MissingSandwichTypes) 110 | }; 111 | }; 112 | 113 | let getDiscountPair = (code, date) => { 114 | let month = date |> Js.Date.getMonth; 115 | let dayOfMonth = date |> Js.Date.getDate; 116 | 117 | switch (code |> Js.String.toUpperCase) { 118 | | "FREE" when month == 4.0 => Ok((`FreeBurgers, getFreeBurgers)) 119 | | "HALF" when month == 4.0 && dayOfMonth == 28.0 => 120 | Ok((`HalfOff, getHalfOff(~date))) 121 | | "HALF" when month == 10.0 && dayOfMonth == 3.0 => 122 | Ok((`SandwichHalfOff, getSandwichHalfOff(~date))) 123 | | "FREE" 124 | | "HALF" => Error(ExpiredCode) 125 | | _ => Error(InvalidCode) 126 | }; 127 | }; 128 | 129 | let getDiscountFunction = (code, date) => 130 | getDiscountPair(code, date) |> Result.map(snd); 131 | -------------------------------------------------------------------------------- /src/promo-component/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | let items: Order.t = [ 3 | Sandwich(Portabello), 4 | Sandwich(Unicorn), 5 | Sandwich(Ham), 6 | Sandwich(Turducken), 7 | Hotdog, 8 | Burger({ 9 | lettuce: true, 10 | tomatoes: true, 11 | onions: 3, 12 | cheese: 2, 13 | bacon: 6, 14 | }), 15 | Burger({ 16 | lettuce: false, 17 | tomatoes: false, 18 | onions: 0, 19 | cheese: 0, 20 | bacon: 0, 21 | }), 22 | Burger({ 23 | lettuce: true, 24 | tomatoes: false, 25 | onions: 1, 26 | cheese: 1, 27 | bacon: 1, 28 | }), 29 | Burger({ 30 | lettuce: false, 31 | tomatoes: false, 32 | onions: 1, 33 | cheese: 0, 34 | bacon: 0, 35 | }), 36 | Burger({ 37 | lettuce: false, 38 | tomatoes: false, 39 | onions: 0, 40 | cheese: 1, 41 | bacon: 0, 42 | }), 43 | ]; 44 | 45 | [@react.component] 46 | let make = () => { 47 | let date = Js.Date.fromString("2024-05-10T00:00"); 48 | 49 |
50 |

{RR.s("Promo")}

51 | 52 |

{RR.s("Order confirmation")}

53 | 54 |
; 55 | }; 56 | }; 57 | 58 | let node = ReactDOM.querySelector("#root"); 59 | switch (node) { 60 | | None => 61 | Js.Console.error("Failed to start React: couldn't find the #root element") 62 | | Some(root) => 63 | let root = ReactDOM.Client.createRoot(root); 64 | ReactDOM.Client.render(root, ); 65 | }; 66 | -------------------------------------------------------------------------------- /src/promo-component/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | 10 | let toEmoji = t => { 11 | let multiple = (emoji, count) => 12 | switch (count) { 13 | | 0 => "" 14 | | 1 => emoji 15 | | count => Printf.sprintf({js|%s×%d|js}, emoji, count) 16 | }; 17 | 18 | switch (t) { 19 | | {lettuce: false, onions: 0, cheese: 0, tomatoes: false, bacon: 0} => {js|🍔|js} 20 | | {lettuce, onions, cheese, tomatoes, bacon} => 21 | let toppingsCount = 22 | (lettuce ? 1 : 0) + (tomatoes ? 1 : 0) + onions + cheese + bacon; 23 | 24 | Printf.sprintf( 25 | {js|🍔%s{%s}|js}, 26 | toppingsCount > 12 ? {js|🥣|js} : "", 27 | [| 28 | lettuce ? {js|🥬|js} : "", 29 | tomatoes ? {js|🍅|js} : "", 30 | multiple({js|🧅|js}, onions), 31 | multiple({js|🧀|js}, cheese), 32 | multiple({js|🥓|js}, bacon), 33 | |] 34 | |> Js.Array.filter(~f=str => str != "") 35 | |> Js.Array.join(~sep=","), 36 | ); 37 | }; 38 | }; 39 | 40 | let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { 41 | let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; 42 | 43 | 15. // base cost 44 | +. toppingCost(onions, 0.2) 45 | +. toppingCost(cheese, 0.1) 46 | +. (tomatoes ? 0.05 : 0.0) 47 | +. toppingCost(bacon, 0.5); 48 | }; 49 | }; 50 | 51 | module Sandwich = { 52 | type t = 53 | | Portabello 54 | | Ham 55 | | Unicorn 56 | | Turducken; 57 | 58 | let toPrice = (~date: Js.Date.t, t) => { 59 | let day = date |> Js.Date.getDay |> int_of_float; 60 | 61 | switch (t) { 62 | | Portabello 63 | | Ham => 10. 64 | | Unicorn => 80. 65 | | Turducken when day == 2 => 10. 66 | | Turducken => 20. 67 | }; 68 | }; 69 | 70 | let toEmoji = t => 71 | Printf.sprintf( 72 | {js|🥪(%s)|js}, 73 | switch (t) { 74 | | Portabello => {js|🍄|js} 75 | | Ham => {js|🐷|js} 76 | | Unicorn => {js|🦄|js} 77 | | Turducken => {js|🦃🦆🐓|js} 78 | }, 79 | ); 80 | }; 81 | 82 | type t = 83 | | Sandwich(Sandwich.t) 84 | | Burger(Burger.t) 85 | | Hotdog; 86 | 87 | let toPrice = (~date: Js.Date.t, t) => { 88 | switch (t) { 89 | | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date) 90 | | Burger(burger) => Burger.toPrice(burger) 91 | | Hotdog => 5. 92 | }; 93 | }; 94 | 95 | let toEmoji = 96 | fun 97 | | Hotdog => {js|🌭|js} 98 | | Burger(burger) => Burger.toEmoji(burger) 99 | | Sandwich(sandwich) => Sandwich.toEmoji(sandwich); 100 | -------------------------------------------------------------------------------- /src/promo-component/ListSafe.re: -------------------------------------------------------------------------------- 1 | /** Return the nth element encased in Some; if it doesn't exist, return None */ 2 | let nth = (n, list) => n < 0 ? None : List.nth_opt(list, n); 3 | -------------------------------------------------------------------------------- /src/promo-component/Order.re: -------------------------------------------------------------------------------- 1 | type t = list(Item.t); 2 | 3 | module OrderItem = { 4 | module Style = { 5 | let item = [%cx {|border-top: 1px solid lightgray;|}]; 6 | let emoji = [%cx {|font-size: 2em;|}]; 7 | let price = [%cx {|text-align: right;|}]; 8 | }; 9 | 10 | [@react.component] 11 | let make = (~item: Item.t, ~date: Js.Date.t) => 12 | 13 | {item |> Item.toEmoji |> RR.s} 14 | 15 | {item |> Item.toPrice(~date) |> RR.currency} 16 | 17 | ; 18 | }; 19 | 20 | module Style = { 21 | let order = [%cx 22 | {| 23 | border-collapse: collapse; 24 | 25 | td { 26 | padding: 0.5em; 27 | } 28 | |} 29 | ]; 30 | 31 | let total = [%cx 32 | {| 33 | border-top: 1px solid gray; 34 | font-weight: bold; 35 | text-align: right; 36 | |} 37 | ]; 38 | }; 39 | 40 | [@react.component] 41 | let make = (~items: t, ~date: Js.Date.t) => { 42 | let total = 43 | items 44 | |> ListLabels.fold_left(~init=0., ~f=(acc, order) => 45 | acc +. Item.toPrice(order, ~date) 46 | ); 47 | 48 | 49 | 50 | {items 51 | |> List.mapi((index, item) => 52 | 53 | ) 54 | |> RR.list} 55 | 56 | 57 | 58 | 59 | 60 |
{RR.s("Total")} {total |> RR.currency}
; 61 | }; 62 | -------------------------------------------------------------------------------- /src/promo-component/Promo.re: -------------------------------------------------------------------------------- 1 | module Style = { 2 | let form = [%cx {| 3 | display: flex; 4 | flex-direction: column; 5 | |}]; 6 | 7 | let input = [%cx 8 | {| 9 | font-family: monospace; 10 | text-transform: uppercase; 11 | |} 12 | ]; 13 | 14 | let codeError = [%cx {|color: red|}]; 15 | 16 | let discountError = [%cx {|color: purple|}]; 17 | }; 18 | 19 | [@react.component] 20 | let make = (~items: list(Item.t), ~date: Js.Date.t) => { 21 | let (code, setCode) = RR.useStateValue(""); 22 | let (submittedCode, setSubmittedCode) = RR.useStateValue(None); 23 | 24 | let discount = 25 | switch (submittedCode) { 26 | | None => `NoSubmittedCode 27 | | Some(code) => 28 | switch (Discount.getDiscountFunction(code, date)) { 29 | | Error(error) => `CodeError(error) 30 | | Ok(discountFunction) => 31 | switch (discountFunction(items)) { 32 | | Error(error) => `DiscountError(error) 33 | | Ok(value) => `Discount(value) 34 | } 35 | } 36 | }; 37 | 38 |
{ 41 | evt |> React.Event.Form.preventDefault; 42 | setSubmittedCode(Some(code)); 43 | }}> 44 | { 48 | evt |> RR.getValueFromEvent |> setCode; 49 | setSubmittedCode(None); 50 | }} 51 | /> 52 | {switch (discount) { 53 | | `NoSubmittedCode => React.null 54 | | `Discount(discount) => discount |> Float.neg |> RR.currency 55 | | `CodeError(error) => 56 |
57 | {let errorType = 58 | switch (error) { 59 | | Discount.InvalidCode => "Invalid" 60 | | ExpiredCode => "Expired" 61 | }; 62 | {j|$errorType promo code|j} |> RR.s} 63 |
64 | | `DiscountError(code) => 65 | let buyWhat = 66 | switch (code) { 67 | | `NeedOneBurger => "at least 1 more burger" 68 | | `NeedTwoBurgers => "at least 2 burgers" 69 | | `NeedMegaBurger => "a burger with every topping" 70 | | `MissingSandwichTypes => "every sandwich" 71 | }; 72 |
73 | {RR.s({j|Buy $buyWhat to enjoy this promotion|j})} 74 |
; 75 | }} 76 |
; 77 | }; 78 | -------------------------------------------------------------------------------- /src/promo-component/RR.re: -------------------------------------------------------------------------------- 1 | /** Get string value from the given event's target */ 2 | let getValueFromEvent = (evt): string => React.Event.Form.target(evt)##value; 3 | 4 | /** Alias for [React.string] */ 5 | let s = React.string; 6 | 7 | /** Render a list of [React.element]s */ 8 | let list = list => list |> Stdlib.Array.of_list |> React.array; 9 | 10 | /** Render a float as currency */ 11 | let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; 12 | 13 | /** Like [React.useState] but doesn't use callback functions */ 14 | let useStateValue = initial => 15 | React.useReducer((_state, newState) => newState, initial); 16 | -------------------------------------------------------------------------------- /src/promo-component/SandwichTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | test("Item.Sandwich.toEmoji", () => { 4 | expect 5 | |> deepEqual( 6 | [|Portabello, Ham, Unicorn, Turducken|] 7 | |> Js.Array.map(~f=Item.Sandwich.toEmoji), 8 | [| 9 | {js|🥪(🍄)|js}, 10 | {js|🥪(🐷)|js}, 11 | {js|🥪(🦄)|js}, 12 | {js|🥪(🦃🦆🐓)|js}, 13 | |], 14 | ) 15 | }); 16 | 17 | test("Item.Sandwich.toPrice", () => { 18 | // 14 Feb 2024 is a Wednesday 19 | let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.); 20 | 21 | expect 22 | |> deepEqual( 23 | [|Portabello, Ham, Unicorn, Turducken|] 24 | |> Js.Array.map(~f=Item.Sandwich.toPrice(~date)), 25 | [|10., 10., 80., 20.|], 26 | ); 27 | }); 28 | 29 | test("Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays", () => { 30 | // Make an array of all dates in a single week; 1 Jan 2024 is a Monday 31 | let dates = 32 | [|1., 2., 3., 4., 5., 6., 7.|] 33 | |> Js.Array.map(~f=date => 34 | Js.Date.makeWithYMD(~year=2024., ~month=0., ~date) 35 | ); 36 | 37 | expect 38 | |> deepEqual( 39 | dates 40 | |> Js.Array.map(~f=date => Item.Sandwich.toPrice(Turducken, ~date)), 41 | [|20., 10., 20., 20., 20., 20., 20.|], 42 | ); 43 | }); 44 | -------------------------------------------------------------------------------- /src/promo-component/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest styled-ppx.melange) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx styled-ppx)) 6 | (module_systems 7 | (es6 mjs))) 8 | 9 | (cram 10 | (deps 11 | (alias melange))) 12 | -------------------------------------------------------------------------------- /src/promo-component/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/promo-component/tests.t: -------------------------------------------------------------------------------- 1 | Sandwich tests 2 | $ node ./output/src/promo-component/SandwichTests.mjs | sed '/duration_ms/d' 3 | TAP version 13 4 | # Subtest: Item.Sandwich.toEmoji 5 | ok 1 - Item.Sandwich.toEmoji 6 | --- 7 | ... 8 | # Subtest: Item.Sandwich.toPrice 9 | ok 2 - Item.Sandwich.toPrice 10 | --- 11 | ... 12 | # Subtest: Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 13 | ok 3 - Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays 14 | --- 15 | ... 16 | 1..3 17 | # tests 3 18 | # suites 0 19 | # pass 3 20 | # fail 0 21 | # cancelled 0 22 | # skipped 0 23 | # todo 0 24 | 25 | Burger tests 26 | $ node ./output/src/promo-component/BurgerTests.mjs | sed '/duration_ms/d' 27 | TAP version 13 28 | # Subtest: A fully-loaded burger 29 | ok 1 - A fully-loaded burger 30 | --- 31 | ... 32 | # Subtest: Burger with 0 of onions, cheese, or bacon doesn't show those emoji 33 | ok 2 - Burger with 0 of onions, cheese, or bacon doesn't show those emoji 34 | --- 35 | ... 36 | # Subtest: Burger with 1 of onions, cheese, or bacon should show just the emoji without × 37 | ok 3 - Burger with 1 of onions, cheese, or bacon should show just the emoji without × 38 | --- 39 | ... 40 | # Subtest: Burger with 2 or more of onions, cheese, or bacon should show × 41 | ok 4 - Burger with 2 or more of onions, cheese, or bacon should show × 42 | --- 43 | ... 44 | # Subtest: Burger with more than 12 toppings should also show bowl emoji 45 | ok 5 - Burger with more than 12 toppings should also show bowl emoji 46 | --- 47 | ... 48 | 1..5 49 | # tests 5 50 | # suites 0 51 | # pass 5 52 | # fail 0 53 | # cancelled 0 54 | # skipped 0 55 | # todo 0 56 | 57 | Discount tests 58 | $ node ./output/src/promo-component/DiscountTests.mjs | sed '/duration_ms/d' 59 | TAP version 13 60 | # Subtest: 0 burgers, no discount 61 | ok 1 - 0 burgers, no discount 62 | --- 63 | ... 64 | # Subtest: 1 burger, no discount 65 | ok 2 - 1 burger, no discount 66 | --- 67 | ... 68 | # Subtest: 2 burgers of same price, discount 69 | ok 3 - 2 burgers of same price, discount 70 | --- 71 | ... 72 | # Subtest: 2 burgers of different price, discount of cheaper one 73 | ok 4 - 2 burgers of different price, discount of cheaper one 74 | --- 75 | ... 76 | # Subtest: 3 burgers of different price, return Ok(15.15) 77 | ok 5 - 3 burgers of different price, return Ok(15.15) 78 | --- 79 | ... 80 | # Subtest: 7 burgers, return Ok(46.75) 81 | ok 6 - 7 burgers, return Ok(46.75) 82 | --- 83 | ... 84 | # Subtest: No burger has 1+ of every topping, return Error(`NeedMegaBurger) 85 | ok 7 - No burger has 1+ of every topping, return Error(`NeedMegaBurger) 86 | --- 87 | ... 88 | # Subtest: One burger has 1+ of every topping, return Ok(15.675) 89 | ok 8 - One burger has 1+ of every topping, return Ok(15.675) 90 | --- 91 | ... 92 | # Subtest: Not all sandwiches, return Error 93 | ok 9 - Not all sandwiches, return Error 94 | --- 95 | ... 96 | # Subtest: All sandwiches, return Ok 97 | ok 10 - All sandwiches, return Ok 98 | --- 99 | ... 100 | # Subtest: Invalid promo code return Error 101 | ok 11 - Invalid promo code return Error 102 | --- 103 | ... 104 | # Subtest: FREE promo code works in May but not other months 105 | ok 12 - FREE promo code works in May but not other months 106 | --- 107 | ... 108 | # Subtest: HALF promo code returns getHalfOff on May 28 but not other days of May 109 | ok 13 - HALF promo code returns getHalfOff on May 28 but not other days of May 110 | --- 111 | ... 112 | # Subtest: HALF promo code returns getSandwichHalfOff on Nov 3 but not other days of Nov 113 | ok 14 - HALF promo code returns getSandwichHalfOff on Nov 3 but not other days of Nov 114 | --- 115 | ... 116 | 1..14 117 | # tests 14 118 | # suites 0 119 | # pass 14 120 | # fail 0 121 | # cancelled 0 122 | # skipped 0 123 | # todo 0 124 | -------------------------------------------------------------------------------- /src/sandwich-tests/Format.re: -------------------------------------------------------------------------------- 1 | let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; 2 | -------------------------------------------------------------------------------- /src/sandwich-tests/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | let items: Order.t = [| 3 | Sandwich(Portabello), 4 | Sandwich(Unicorn), 5 | Sandwich(Ham), 6 | Sandwich(Turducken), 7 | Hotdog, 8 | Burger({ 9 | lettuce: true, 10 | tomatoes: true, 11 | onions: 3, 12 | cheese: 2, 13 | bacon: 6, 14 | }), 15 | Burger({ 16 | lettuce: false, 17 | tomatoes: false, 18 | onions: 0, 19 | cheese: 0, 20 | bacon: 0, 21 | }), 22 | Burger({ 23 | lettuce: true, 24 | tomatoes: false, 25 | onions: 1, 26 | cheese: 1, 27 | bacon: 1, 28 | }), 29 | Burger({ 30 | lettuce: false, 31 | tomatoes: false, 32 | onions: 1, 33 | cheese: 0, 34 | bacon: 0, 35 | }), 36 | Burger({ 37 | lettuce: false, 38 | tomatoes: false, 39 | onions: 0, 40 | cheese: 1, 41 | bacon: 0, 42 | }), 43 | |]; 44 | 45 | [@react.component] 46 | let make = () => 47 |
48 |

{React.string("Order confirmation")}

49 | 50 |
; 51 | }; 52 | 53 | let node = ReactDOM.querySelector("#root"); 54 | switch (node) { 55 | | None => 56 | Js.Console.error("Failed to start React: couldn't find the #root element") 57 | | Some(root) => 58 | let root = ReactDOM.Client.createRoot(root); 59 | ReactDOM.Client.render(root, ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/sandwich-tests/Item.re: -------------------------------------------------------------------------------- 1 | module Burger = { 2 | type t = { 3 | lettuce: bool, 4 | onions: int, 5 | cheese: int, 6 | tomatoes: bool, 7 | bacon: int, 8 | }; 9 | 10 | let toEmoji = t => { 11 | let multiple = (emoji, count) => 12 | switch (count) { 13 | | 0 => "" 14 | | 1 => emoji 15 | | count => Printf.sprintf({js|%s×%d|js}, emoji, count) 16 | }; 17 | 18 | switch (t) { 19 | | {lettuce: false, onions: 0, cheese: 0, tomatoes: false, bacon: 0} => {js|🍔|js} 20 | | {lettuce, onions, cheese, tomatoes, bacon} => 21 | Printf.sprintf( 22 | {js|🍔{%s}|js}, 23 | [| 24 | lettuce ? {js|🥬|js} : "", 25 | tomatoes ? {js|🍅|js} : "", 26 | multiple({js|🧅|js}, onions), 27 | multiple({js|🧀|js}, cheese), 28 | multiple({js|🥓|js}, bacon), 29 | |] 30 | |> Js.Array.filter(~f=str => str != "") 31 | |> Js.Array.join(~sep=","), 32 | ) 33 | }; 34 | }; 35 | 36 | let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { 37 | let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; 38 | 39 | 15. // base cost 40 | +. toppingCost(onions, 0.2) 41 | +. toppingCost(cheese, 0.1) 42 | +. (tomatoes ? 0.05 : 0.0) 43 | +. toppingCost(bacon, 0.5); 44 | }; 45 | }; 46 | 47 | module Sandwich = { 48 | type t = 49 | | Portabello 50 | | Ham 51 | | Unicorn 52 | | Turducken; 53 | 54 | let toPrice = (~date: Js.Date.t, t) => { 55 | let day = date |> Js.Date.getDay |> int_of_float; 56 | 57 | switch (t) { 58 | | Portabello 59 | | Ham => 10. 60 | | Unicorn => 80. 61 | | Turducken when day == 2 => 10. 62 | | Turducken => 20. 63 | }; 64 | }; 65 | 66 | let toEmoji = t => 67 | Printf.sprintf( 68 | {js|🥪(%s)|js}, 69 | switch (t) { 70 | | Portabello => {js|🍄|js} 71 | | Ham => {js|🐷|js} 72 | | Unicorn => {js|🦄|js} 73 | | Turducken => {js|🦃🦆🐓|js} 74 | }, 75 | ); 76 | }; 77 | 78 | type t = 79 | | Sandwich(Sandwich.t) 80 | | Burger(Burger.t) 81 | | Hotdog; 82 | 83 | let toPrice = t => { 84 | switch (t) { 85 | | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date=Js.Date.make()) 86 | | Burger(burger) => Burger.toPrice(burger) 87 | | Hotdog => 5. 88 | }; 89 | }; 90 | 91 | let toEmoji = 92 | fun 93 | | Hotdog => {js|🌭|js} 94 | | Burger(burger) => Burger.toEmoji(burger) 95 | | Sandwich(sandwich) => Sandwich.toEmoji(sandwich); 96 | -------------------------------------------------------------------------------- /src/sandwich-tests/Order.re: -------------------------------------------------------------------------------- 1 | type t = array(Item.t); 2 | 3 | module OrderItem = { 4 | [@mel.module "./order-item.module.css"] 5 | external css: Js.t({..}) = "default"; 6 | 7 | [@react.component] 8 | let make = (~item: Item.t) => 9 | 10 | {item |> Item.toEmoji |> React.string} 11 | {item |> Item.toPrice |> Format.currency} 12 | ; 13 | }; 14 | 15 | [@mel.module "./order.module.css"] external css: Js.t({..}) = "default"; 16 | 17 | [@react.component] 18 | let make = (~items: t) => { 19 | let total = 20 | items 21 | |> Js.Array.reduce(~init=0., ~f=(acc, order) => 22 | acc +. Item.toPrice(order) 23 | ); 24 | 25 | 26 | 27 | {items 28 | |> Js.Array.mapi(~f=(item, index) => 29 | 30 | ) 31 | |> React.array} 32 | 33 | 34 | 35 | 36 | 37 |
{React.string("Total")} {total |> Format.currency}
; 38 | }; 39 | -------------------------------------------------------------------------------- /src/sandwich-tests/SandwichTests.re: -------------------------------------------------------------------------------- 1 | open Fest; 2 | 3 | test("Item.Sandwich.toEmoji", () => { 4 | expect 5 | |> deepEqual( 6 | [|Portabello, Ham, Unicorn, Turducken|] 7 | |> Js.Array.map(~f=Item.Sandwich.toEmoji), 8 | [| 9 | {js|🥪(🍄)|js}, 10 | {js|🥪(🐷)|js}, 11 | {js|🥪(🦄)|js}, 12 | {js|🥪(🦃🦆🐓)|js}, 13 | |], 14 | ) 15 | }); 16 | 17 | test("Item.Sandwich.toPrice", () => { 18 | // 14 Feb 2024 is a Wednesday 19 | let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.); 20 | 21 | expect 22 | |> deepEqual( 23 | [|Portabello, Ham, Unicorn, Turducken|] 24 | |> Js.Array.map(~f=Item.Sandwich.toPrice(~date)), 25 | [|10., 10., 80., 20.|], 26 | ); 27 | }); 28 | 29 | test("Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays", () => { 30 | // Make an array of all dates in a single week; 1 Jan 2024 is a Monday 31 | let dates = 32 | [|1., 2., 3., 4., 5., 6., 7.|] 33 | |> Js.Array.map(~f=date => 34 | Js.Date.makeWithYMD(~year=2024., ~month=0., ~date) 35 | ); 36 | 37 | expect 38 | |> deepEqual( 39 | dates 40 | |> Js.Array.map(~f=date => Item.Sandwich.toPrice(Turducken, ~date)), 41 | [|20., 10., 20., 20., 20., 20., 20.|], 42 | ); 43 | }); 44 | -------------------------------------------------------------------------------- /src/sandwich-tests/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react melange-fest) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems 7 | (es6 mjs)) 8 | (runtime_deps 9 | (glob_files *.css))) 10 | -------------------------------------------------------------------------------- /src/sandwich-tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/sandwich-tests/order-item.module.css: -------------------------------------------------------------------------------- 1 | .item { 2 | border-top: 1px solid lightgray; 3 | } 4 | 5 | .emoji { 6 | font-size: 2em; 7 | } 8 | 9 | .price { 10 | text-align: right; 11 | } 12 | -------------------------------------------------------------------------------- /src/sandwich-tests/order.module.css: -------------------------------------------------------------------------------- 1 | table.order { 2 | border-collapse: collapse; 3 | } 4 | 5 | table.order td { 6 | padding: 0.5em; 7 | } 8 | 9 | .total { 10 | border-top: 1px solid gray; 11 | font-weight: bold; 12 | text-align: right; 13 | } 14 | -------------------------------------------------------------------------------- /src/styling-with-css/Format.re: -------------------------------------------------------------------------------- 1 | let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; 2 | -------------------------------------------------------------------------------- /src/styling-with-css/Index.re: -------------------------------------------------------------------------------- 1 | module App = { 2 | let items: Order.t = [|Sandwich, Burger, Sandwich, Hotdog|]; 3 | 4 | [@react.component] 5 | let make = () => 6 |
7 |

{React.string("Order confirmation")}

8 | 9 |
; 10 | }; 11 | 12 | let node = ReactDOM.querySelector("#root"); 13 | switch (node) { 14 | | None => 15 | Js.Console.error("Failed to start React: couldn't find the #root element") 16 | | Some(root) => 17 | let root = ReactDOM.Client.createRoot(root); 18 | ReactDOM.Client.render(root, ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/styling-with-css/Item.re: -------------------------------------------------------------------------------- 1 | type t = 2 | | Sandwich 3 | | Burger 4 | | Hotdog; 5 | 6 | let toPrice = 7 | fun 8 | | Sandwich => 10. 9 | | Burger => 15. 10 | | Hotdog => 5.; 11 | 12 | let toEmoji = 13 | fun 14 | | Sandwich => {js|🥪|js} 15 | | Burger => {js|🍔|js} 16 | | Hotdog => {js|🌭|js}; 17 | -------------------------------------------------------------------------------- /src/styling-with-css/Order.re: -------------------------------------------------------------------------------- 1 | type t = array(Item.t); 2 | 3 | module OrderItem = { 4 | [@mel.module "./order-item.module.css"] 5 | external css: Js.t({..}) = "default"; 6 | 7 | [@react.component] 8 | let make = (~item: Item.t) => 9 | 10 | {item |> Item.toEmoji |> React.string} 11 | {item |> Item.toPrice |> Format.currency} 12 | ; 13 | }; 14 | 15 | [@mel.module "./order.module.css"] external css: Js.t({..}) = "default"; 16 | 17 | [@react.component] 18 | let make = (~items: t) => { 19 | let total = 20 | items 21 | |> Js.Array.reduce(~init=0., ~f=(acc, order) => 22 | acc +. Item.toPrice(order) 23 | ); 24 | 25 | 26 | 27 | {items 28 | |> Js.Array.mapi(~f=(item, index) => 29 | 30 | ) 31 | |> React.array} 32 | 33 | 34 | 35 | 36 | 37 |
{React.string("Total")} {total |> Format.currency}
; 38 | }; 39 | -------------------------------------------------------------------------------- /src/styling-with-css/dune: -------------------------------------------------------------------------------- 1 | (melange.emit 2 | (target output) 3 | (libraries reason-react) 4 | (preprocess 5 | (pps melange.ppx reason-react-ppx)) 6 | (module_systems es6) 7 | (runtime_deps 8 | (glob_files *.css))) 9 | -------------------------------------------------------------------------------- /src/styling-with-css/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Melange for React Devs 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/styling-with-css/order-item.module.css: -------------------------------------------------------------------------------- 1 | .item { 2 | border-top: 1px solid lightgray; 3 | } 4 | 5 | .emoji { 6 | font-size: 2em; 7 | } 8 | 9 | .price { 10 | text-align: right; 11 | } 12 | -------------------------------------------------------------------------------- /src/styling-with-css/order.module.css: -------------------------------------------------------------------------------- 1 | table.order { 2 | border-collapse: collapse; 3 | } 4 | 5 | table.order td { 6 | padding: 0.5em; 7 | } 8 | 9 | .total { 10 | border-top: 1px solid gray; 11 | font-weight: bold; 12 | text-align: right; 13 | } 14 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig } from 'vite' 3 | import { nodeResolve } from '@rollup/plugin-node-resolve' 4 | 5 | export default defineConfig({ 6 | base: '/demo/', 7 | // nodeResolve is needed to import melange runtime libraries in output 8 | // directory's `node_modules` directory. See 9 | // https://github.com/melange-re/melange-for-react-devs/pull/16#discussion_r1455989106 10 | plugins: [nodeResolve()], 11 | server: { 12 | watch: { 13 | ignored: ['**/_opam'] 14 | } 15 | }, 16 | build: { 17 | rollupOptions: { 18 | input: { 19 | 'main': resolve(__dirname, 'index.html'), 20 | 'counter': resolve(__dirname, 'src/counter/index.html'), 21 | 'numeric-types': resolve(__dirname, 'src/numeric-types/index.html'), 22 | 'celsius-converter-exception': resolve(__dirname, 'src/celsius-converter-exception/index.html'), 23 | 'celsius-converter-option': resolve(__dirname, 'src/celsius-converter-option/index.html'), 24 | 'order-confirmation': resolve(__dirname, 'src/order-confirmation/index.html'), 25 | 'styling-with-css': resolve(__dirname, 'src/styling-with-css/index.html'), 26 | 'better-sandwiches': resolve(__dirname, 'src/better-sandwiches/index.html'), 27 | 'better-burgers': resolve(__dirname, 'src/better-burgers/index.html'), 28 | 'sandwich-tests': resolve(__dirname, 'src/sandwich-tests/index.html'), 29 | 'cram-tests': resolve(__dirname, 'src/cram-tests/index.html'), 30 | 'burger-discounts': resolve(__dirname, 'src/burger-discounts/index.html'), 31 | 'discounts-lists': resolve(__dirname, 'src/discounts-lists/index.html'), 32 | 'promo-codes': resolve(__dirname, 'src/promo-codes/index.html'), 33 | 'promo-component': resolve(__dirname, 'src/promo-component/index.html'), 34 | 'order-with-promo': resolve(__dirname, 'src/order-with-promo/index.html'), 35 | }, 36 | }, 37 | }, 38 | }); 39 | --------------------------------------------------------------------------------