├── .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 | {React.string("Total")} |
36 | {total |> Format.currency} |
37 |
38 |
39 |
;
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 | {React.string("Total")} |
17 | {total |> Js.Float.toFixed(~digits=2) |> React.string} |
18 |
19 |
20 |
;
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 | {React.string("Total")} |
53 | {total |> Js.Float.toFixed(~digits=2) |> React.string} |
54 |
55 |
56 |
;
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 | {RR.s("Subtotal")} |
43 | {subtotal |> RR.currency} |
44 |
45 |
46 | {RR.s("Promo code")} |
47 | |
48 |
49 |
50 | {RR.s("Total")} |
51 | {subtotal -. discount |> RR.currency} |
52 |
53 |
54 |
;
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 | {RR.s("Total")} |
55 | {total |> RR.currency} |
56 |
57 |
58 |
;
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 | {React.string("Total")} |
55 | {total |> Format.currency} |
56 |
57 |
58 |
;
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 | {React.string("Total")} |
101 | {total |> Format.currency} |
102 |
103 |
104 |
;
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 | {React.string("Total")} |
34 | {total |> Format.currency} |
35 |
36 |
37 |
;
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 | {React.string("Total")} |
34 | {total |> Format.currency} |
35 |
36 |
37 |
;
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 | {React.string("Total")} |
34 | {total |> Format.currency} |
35 |
36 |
37 |
;
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 | {React.string("Total")} |
34 | {total |> Format.currency} |
35 |
36 |
37 |
;
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 | {React.string("Total")} |
35 | {total |> Format.currency} |
36 |
37 |
38 |
;
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 | {React.string("Total")} |
29 | {total |> Format.currency} |
30 |
31 |
32 |
;
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 | {RR.s("Subtotal")} |
67 | {subtotal |> RR.currency} |
68 |
69 |
70 | {RR.s("Promo code")} |
71 | |
72 |
73 |
74 | {RR.s("Total")} |
75 | {subtotal -. discount |> RR.currency} |
76 |
77 |
78 |
;
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 | ;
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 | {React.string("Total")} |
35 | {total |> Format.currency} |
36 |
37 |
38 |
;
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 | {RR.s("Total")} |
57 | {total |> RR.currency} |
58 |
59 |
60 |
;
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 | ;
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 | {React.string("Total")} |
34 | {total |> Format.currency} |
35 |
36 |
37 |
;
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 | {React.string("Total")} |
34 | {total |> Format.currency} |
35 |
36 |
37 |
;
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 |
--------------------------------------------------------------------------------