brid před 1 rokem
revize
725a7d0c0b
100 změnil soubory, kde provedl 17084 přidání a 0 odebrání
  1. 17 0
      .gitignore
  2. 1 0
      .python-version
  3. 224 0
      README.md
  4. 0 0
      app/__init__.py
  5. 268 0
      app/cart.py
  6. 585 0
      app/db.py
  7. 86 0
      app/email.py
  8. 50 0
      app/europe.json
  9. 74 0
      app/form_validation.py
  10. 136 0
      app/git.py
  11. 1163 0
      app/main.py
  12. 342 0
      app/parser.py
  13. 182 0
      app/payment.py
  14. 35 0
      app/read_settings.py
  15. 355 0
      app/schema.py
  16. 236 0
      app/serializer.py
  17. 130 0
      app/shipping-cost.json
  18. 440 0
      app/template.py
  19. 55 0
      app/templates/base.html
  20. 15 0
      app/templates/checkout-error.html
  21. 17 0
      app/templates/checkout-success.html
  22. 74 0
      app/templates/checkout.html
  23. 26 0
      app/templates/error.html
  24. 42 0
      app/templates/index.html
  25. 28 0
      app/templates/page.html
  26. 12 0
      app/templates/partials/block-image-cover.html
  27. 12 0
      app/templates/partials/block-image.html
  28. 1 0
      app/templates/partials/block-text.html
  29. 3 0
      app/templates/partials/cart.html
  30. 30 0
      app/templates/partials/checkout-item-form.html
  31. 52 0
      app/templates/partials/checkout-item.html
  32. 13 0
      app/templates/partials/footer.html
  33. 11 0
      app/templates/partials/form-payment-options.html
  34. 49 0
      app/templates/partials/form-shipping-info.html
  35. 6 0
      app/templates/partials/form-subtotal.html
  36. 8 0
      app/templates/partials/form-total.html
  37. 13 0
      app/templates/partials/grid-product.html
  38. 3 0
      app/templates/partials/icon-minus.html
  39. 4 0
      app/templates/partials/icon-plus.html
  40. 3 0
      app/templates/partials/menu.html
  41. 18 0
      app/templates/partials/nav.html
  42. 35 0
      app/templates/partials/product-form.html
  43. 57 0
      app/templates/product.html
  44. 49 0
      build-assets.sh
  45. 188 0
      build_image_cache.py
  46. 31 0
      darkfi.nginx
  47. 5 0
      dev-server.sh
  48. 122 0
      devenv.lock
  49. 13 0
      devenv.nix
  50. 27 0
      env.sample
  51. 32 0
      post-receive
  52. 23 0
      pyproject.toml
  53. 87 0
      requirements.txt
  54. 11 0
      server.sh
  55. 14 0
      settings.sample.toml
  56. binární
      static/android-chrome-192x192.png
  57. binární
      static/android-chrome-512x512.png
  58. binární
      static/apple-touch-icon.png
  59. 1549 0
      static/css/styles.css
  60. 187 0
      static/dist/checkout-7PTSF3BQ.js
  61. binární
      static/dist/fonts/IBMPlexMono-Light.woff
  62. binární
      static/dist/fonts/IBMPlexMono-Light.woff2
  63. binární
      static/dist/fonts/IBMPlexMono-LightItalic.woff
  64. binární
      static/dist/fonts/IBMPlexMono-LightItalic.woff2
  65. binární
      static/dist/fonts/Inter-Bold.woff
  66. binární
      static/dist/fonts/Inter-Bold.woff2
  67. binární
      static/dist/fonts/Inter-BoldItalic.woff
  68. binární
      static/dist/fonts/Inter-BoldItalic.woff2
  69. binární
      static/dist/fonts/weissrundgotisch.regular.woff
  70. binární
      static/dist/fonts/weissrundgotisch.regular.woff2
  71. 116 0
      static/dist/product-R6DMP5AX.js
  72. 1208 0
      static/dist/styles-3XFNZ3JU.css
  73. 1161 0
      static/dist/styles-DJRXFP6Z.css
  74. 1126 0
      static/dist/styles-HTE34KG6.css
  75. 1082 0
      static/dist/styles-JIS64YTB.css
  76. 1211 0
      static/dist/styles-K6KOU2HW.css
  77. 1122 0
      static/dist/styles-SUK2W4LI.css
  78. 1225 0
      static/dist/styles-XHZV2ERZ.css
  79. 1194 0
      static/dist/styles-ZJO4NQRT.css
  80. binární
      static/favicon-16x16.png
  81. binární
      static/favicon-32x32.png
  82. binární
      static/favicon.ico
  83. binární
      static/favicon.png
  84. binární
      static/fonts/IBMPlexMono-Light.woff
  85. binární
      static/fonts/IBMPlexMono-Light.woff2
  86. binární
      static/fonts/IBMPlexMono-LightItalic.woff
  87. binární
      static/fonts/IBMPlexMono-LightItalic.woff2
  88. binární
      static/fonts/Inter-Bold.woff
  89. binární
      static/fonts/Inter-Bold.woff2
  90. binární
      static/fonts/Inter-BoldItalic.woff
  91. binární
      static/fonts/Inter-BoldItalic.woff2
  92. binární
      static/fonts/weissrundgotisch.regular.woff
  93. binární
      static/fonts/weissrundgotisch.regular.woff2
  94. binární
      static/images/coinpayments.png
  95. binární
      static/images/sunset-green.png
  96. binární
      static/images/sunset-red.png
  97. 170 0
      static/js/cart.js
  98. 122 0
      static/js/checkout.js
  99. 97 0
      static/js/product.js
  100. 1 0
      static/site.webmanifest

+ 17 - 0
.gitignore

@@ -0,0 +1,17 @@
+# Devenv
+.devenv*
+devenv.local.nix
+
+# direnv
+.direnv
+
+env
+*.pyc
+
+.DS_Store
+settings.toml
+static/images/cache
+.env
+logs.log
+
+esbuild

+ 1 - 0
.python-version

@@ -0,0 +1 @@
+3.12

+ 224 - 0
README.md

@@ -0,0 +1,224 @@
+# hardware.dark.fi webshop
+
+website for the Dark.Fi's hardware webshop. 
+
+python-based website integrating a webshop (Stripe, NOWPayments) and git-based workflow to update website content:
+
+- python 3.12
+- FastAPI
+- custom parser for md + yaml documents
+
+## setup
+
+as the content repository is tracked with git, we're using git-annex to handle binary files. to setup this integration:
+
+- install `git annex` (see <https://git-annex.branchable.com/install/>)
+  - if on Debian based systems, check the [NeuroDebian](http://neuro.debian.net) channel for a more up-to-date version of git-annex
+- add a `.gitattributes` file with the following:
+
+```
+*          annex.largefiles=nothing
+*.png      filter=annex annex.largefiles=anything
+*.jpg      filter=annex annex.largefiles=anything
+*.pdf      filter=annex annex.largefiles=anything
+*.epub     filter=annex annex.largefiles=anything
+```
+
+add more file type as needed. after this, we don't need to track binary files with `git annex add` but we can simply use `git add`.
+
+- initialize the repo: `git init`, `git annex init`
+- then work with files with git (`git add`, `git mv`, `git rm`, etc)
+
+### git annex useful commands
+
+- general sync: `git annex sync --content`; this syncs both from your computer to the remote server and vice-versa (eg it does a pull and then a push)
+- list files: `git annex find`
+- get copy of file: `git annex get <filepath>` (or without filepath to get all listed files)
+- remove file: `git annex drop --force <filepath>` (or without `<filepath>` to remove all listed files)
+  - we need to use `--force` because by default git-annex tries to keep at least 1 more copy of the file in another repo, before deleting the file; in eg, it acts as an archive-focused system. since we might not care about that, passing `--force` bypasses the normal mechanism
+  - NB: using `git rm <filepath>` will not remove the file from the repo, only the symlink git-annex created for it; we can check this by doing `ls -l ./git/annex/objects/` and count how many items are in there
+
+## development
+
+### settings.toml
+
+rename `settings.sample.toml` to `settings.toml` and fill out fields:
+
+- `git_repo`: set the path to the content folder (it can also be a relative path)
+- `document_match`: list of file extension to match
+- `document_exclude`: list of files to exclude
+- `block_types`: set necessary block types (for the content document); for starting, the default ones should be enough; the website's parser read from this list of block types to validate if a given block is "allowed"
+- `local_db.filepath`: path to local DB, eg file that contains the orders
+- `order_upload.filepath`: path to folder where new XLSX file order are exported to (these files are used to upload a shipping order on the Swiss Post website)
+- `currency.default`: default currency for Stripe
+- `currency.now_payments`: default currency for NOWPayments
+
+## .env
+
+copy the file `env.sample` and rename it `.env`. then update its content accordingly:
+
+- `ENV`: set if environment is `development` or `production` (it affects some of the code logic)
+- `CACHE`: set `enable` or `disable` to cache CSS, JS and font files
+- `CSRF_SECRET_KEY`: generate a secret (a random string of characters) for the CSRF protection, see <https://stackoverflow.com/a/54433731>; for example:
+  ```
+  python
+ 
+  >>> import secrets
+  >>> secrets.token_hex(16)
+  ```
+- `SESSION_SECRET_KEY`: see item above for how to generate a random string
+
+- `STRIPE_SECRET_KEY`: Stripe API key
+- `STRIPE_ENDPOINT_SECRET`: Stripe webhook secret
+  - important: use keys generated from Test mode while working on the website
+  
+- `NOWPAYMENTS_API_KEY`: NOWPayments API key
+- `NOWPAYMENTS_SANDBOX_IPN`: NOWPayments IPN
+- `NOWPAYMENTS_SANDBOX_API_KEY`: NOWPayments Sandbox API key
+- `NOWPAYMENTS_SANDBOX_IPN`: NOWPayments Sandbox IPN
+
+- `MAIL_USERNAME`: email address
+- `MAIL_PASSWORD`: email password
+- `MAIL_FROM`: email sender address
+- `MAIL_FROM_NAME`: email sender name
+- `MAIL_PORT`: email port
+- `MAIL_SERVER`: email server
+- `MAIL_STARTTLS`: True|False
+- `MAIL_SSL_TLS`: True|False
+- `MAIL_USE_CREDENTIALS`: True|False
+- `MAIL_VALIDATE_CERTS`: True|False
+
+- `CPU_CORE`: set computer's number of CPU cores, on Linux run `nproc --all`; this is used by the `build_image_cache.py` command to generate images of various sizes for the frontend.
+
+### setup Python
+
+- python 3.12
+- make virtual environment: `python -m venv env`
+- activate virtual environment: `source env/bin/activate`
+- install packages with `pip install -r requirements.txt`
+
+### packages
+
+whenever installing a new package, save it to `requirements.txt` by doing:
+
+```
+pip freeze > requirements.txt
+```
+
+### server
+
+to run the local web-server, type:
+
+```
+./dev-server.sh 
+```
+
+to run the production server, do:
+
+```
+./server.sh
+```
+
+### create image cache
+
+we display images in various sizes so that the browser can pick the best image in terms of quality / size ratio — for instance by not downloading a high-res image prepared for a big screen if the browser request comes from a small screen device (eg. a phone). to deal with this, there a CLI script called `build_image_cache.py` at the root of the repo. it offers two commands:
+
+- `build-all-images`: this build all images across the content repo. if an image exists already in the `./static/images/cache/` folder, it will skip it. you can either remove it from the cache folder or rename the image to a new name to force-recreate it. run this command like so: `python build_image_cache.py build-all-images '<path/to/content-repo>`
+- `build-images`: this command is used in the git post-receive hook in the git bare repo (on the VPS). it checks if the last pushed commit contains also image files, and if so it creates the necessary sizes of each image for the website. check the `post-receive` files for the details
+
+example:
+
+```
+python build_image_cache.py build-all-images <path/to/content-folder>
+```
+
+### test stripe webhook (locally)
+
+unlike with NOWPayments, we can locally test that the Stripe webhook works as intended — for instance that it triggers the order status update, the product inventory update, and the email confirmation.
+
+to do so:
+
+- first install the [Stripe CLI](https://docs.stripe.com/stripe-cli)
+- then open a new shell and run:
+
+```
+stripe listen --load-from-webhooks-api --forward-to http://127.0.0.1:5014
+```
+
+where `http://127.0.0.1:5014` is your local server port (which is the one set in `dev-server.sh`). this command listen to our "online" Stripe webhook for any event, and redirects them all to our local environment.
+
+when you run Stripe CLI locally, it might print the webhook signature valid for our local environment — make sure to add it or replace it to the field `STRIPE_TEST_ENDPOINT_SECRET` in the `.env` file.
+
+some refs:
+
+- [Receive Stripe events in your webhook endpoint](https://docs.stripe.com/webhooks#local-listener)
+- [Resolve webhook signature verification errors](https://docs.stripe.com/webhooks/signature)
+
+aftewards, run whatever you need to test.
+
+## server setup
+
+### installation
+
+- make new unix user from root (`sudo adduser`)
+- add ssh pubkey to new user's home under `~/.ssh/authorized_keys`, set permissions (https://superuser.com/a/925859)
+- add newly created user under `sudo` group (`sudo adduser <user> sudo`)
+- update `/etc/ssh/ssh_config` by changing or adding values to the following:
+```
+# disable ssh login for root user
+PermitRootLogin no
+ChallengeResponseAuthentication no
+PasswordAuthentication no
+UsePAM no
+
+# remove password-based login for any user
+AuthenticationMethods publickey
+PubkeyAuthentication yes
+
+# limit ssh access to specified users (IMPORTANT: this will block any other previously authorized user to login via ssh)
+AllowUsers <user-1> <user-2>
+
+# disable empty password
+PermitEmptyPasswords no
+
+# disable X11Forwarding
+X11Forwarding no
+```
+
+then restart sshd with `sudo systemctl restart sshd`
+
+- install ufw and enable SSH + Nginx
+- install git
+- install `pyenv` (https://bgasparotto.com/install-pyenv-ubuntu-debian):
+  - `sudo apt-get install -y make build-essential libssl-dev zlib1g-dev libbz2-dev \
+libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev libncursesw5-dev \
+xz-utils tk-dev libffi-dev liblzma-dev`
+  - `curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer | bash`
+  - `pyenv install 3.12`
+- make git bare repo for source code and add `post-receive` hook:
+```
+#!/bin/bash
+GIT_WORK_TREE=/var/www/hardware.darkfi git checkout -f main
+```
+  - make hook executable with `chmod +x hooks/post-receive`
+- make folder for website at `/var/www/hardware.darkfi`; create a new git remote pointing to `<user>@<server-ip>:www-code.git` and then push to this remote (it should copy all files over to `/var/www/darkfi-webshop`)
+- install nginx and copy sample file to `/etc/nginx/site-available/`; enable nginx site by making a symbolic link of it to `/etc/nginx/site-enabled/`: `sudo ln -s /etc/nginx/site-available/hardware.darkfi.nginx /etc/nginx/site-enabled/hardware.darkfi.nginx`
+- install git-annex:
+  - `sudo apt-get install neurodebian`
+  - `sudo apt-get update`
+  - `sudo apt install git-annex`
+- make new file `/etc/systemd/system/darkfi.service` and copy sample
+- `sudo systemctl enable darkfi`, `sudo systemctl start darkfi`
+
+- setup git user info for current user:
+  - `git config --global user.email "you@example.com"`
+  - `git config --global user.name "Your Name"`
+- make git bare repo for content:
+  - `mkdir www-data`
+  - `cd www-data && git init --bare www-data.git`
+  - `git clone www-data.git content`
+  - `cd content && git annex init`
+  - add `post-receive` script to `~/www-data/www-data.git/hooks/`
+  - run `chmod +x ~/www-data/www-data.git/hooks/post-receive`.
+
+this will synchronize the git checkout folder (at `~/www-data/content`) with the git bare repo we use for actual synchronization (at `~/www-data/www-data.git`).

+ 0 - 0
app/__init__.py


+ 268 - 0
app/cart.py

@@ -0,0 +1,268 @@
+from pathlib import Path
+from typing import NoReturn
+
+import shortuuid
+
+from app.schema import CartUpdate, DocumentProduct, create_product_id, CartSessionItem, Settings, ProductInfoCode
+from app.template import make_product_info_code
+from app.db import get_product_by_product_id
+
+
+def product_exist_in_cart(
+    product_id: str,
+    size: str | None,
+    style: str | None,
+    session_cart_items: dict[str, dict[str, str | dict[str, str]]],
+) -> bool:
+    """Check if given product exists in the cart already by constructing
+    a full label using the product's id and eventual size and style
+    options.
+    """
+    product_id_label = create_product_id(product_id, size, style)
+
+    if product_id_label in session_cart_items:
+        return True
+    else:
+        return False
+
+
+def initialize_cart(session, currency: str) -> dict[int, str] | NoReturn:
+    """Initialize cart if no cart object is present yet in session."""
+    if "cart" not in session:
+        order_id = shortuuid.uuid()
+
+        session["cart"] = {
+            "meta": {
+                "order_id": order_id,
+                "currency": currency,
+                "subtotal": 0,
+                "shipping": 0,
+                "total": 0,
+                "item_amount": 0,
+            },
+            "items": {},
+        }
+
+
+def get_inventory_amount(session_item, products) -> int | None:
+    """Returns the inventory amount value for any product by matching it
+    against the list of selected producs in the cart. We need to make
+    use of the selected product's style and size options in order to
+    retrieve the correct inventory amount value.
+    """
+    inventory_amount = None
+
+    for product in products:
+        if product.meta.product_id == session_item["product_id"]:
+            for inventory in product.meta.inventory:
+                if (
+                    inventory.size == session_item["options"]["size"]
+                    and inventory.style == session_item["options"]["style"]
+                ):
+                    inventory_amount = inventory.amount
+
+    if inventory_amount:
+        return inventory_amount
+
+
+def update_cart_meta(
+        session,
+        shipping_amount: int,
+        products: list[DocumentProduct] | None = None
+) -> NoReturn:
+    """Update Meta key of Session Cart (subtotal, shipping, total).
+
+    Re-compute Meta's item_count, subtotal, shipping and total from
+    each item so that we don't need to check for inventory's item
+    amount a second time (we did it when updating
+    each item in update_cart).
+    """
+    session_meta_item_amount = 0
+    session_meta_subtotal = 0
+
+    for item in session["items"].values():
+        if products is not None:
+            # when doing GET /checkout, checks that user's cart can
+            # proceed with the payment step or if not:
+            # - either remove any sold out item from the cart
+            # - or adjust user's cart to the max available number of
+            #   items set in the inventory of the given item
+
+            inventory_amount = get_inventory_amount(item, products)
+
+            if inventory_amount == 0:
+                # if inventory amount for given item has meanwhile gone to 0,
+                # don't update the meta fields
+                pass
+
+            else:
+                # else re-compute meta values
+                item_quantity = 0
+
+                if item["quantity"] >= inventory_amount:
+                    item_quantity = inventory_amount
+                else:
+                    item_quantity = item["quantity"]
+
+                session_meta_item_amount += item_quantity
+                session_meta_subtotal += item_quantity * item["price"]
+
+            # -- update also each item quantity and availability under session.cart.items
+            cart_product_id = create_product_id(
+                item["product_id"], item["options"]["size"], item["options"]["style"]
+            )
+
+            session["items"][cart_product_id]["quantity"] = item_quantity
+            session["items"][cart_product_id]["availability"] = inventory_amount > 0
+
+        else:
+            # when doing POST /checkout, re-compute meta values
+            session_meta_item_amount += item["quantity"]
+            session_meta_subtotal += item["quantity"] * item["price"]
+
+    session["meta"]["item_amount"] = session_meta_item_amount
+    session["meta"]["subtotal"] = session_meta_subtotal
+
+    # check items amount and eventually reset shipping value
+    if len(session["items"]) > 0:
+        session["meta"]["shipping"] = shipping_amount
+    else:
+        session["meta"]["shipping"] = 0
+
+    # update session meta total
+    session["meta"]["total"] = session["meta"]["subtotal"] + shipping_amount
+
+
+def update_cart(
+    session,
+    form: CartUpdate,
+    product_exists: bool,
+    shipping_amount: int,
+    quantity: int,
+    inventory_amount: int,
+) -> NoReturn:
+    """Update (add / remove) Session Cart with given product_id + options
+    item: if Cart contains the product, increase / decrease product's
+    quantity, else add / remove the product to / from the Cart.
+    """
+    product_id = create_product_id(
+        form.product_id, form.options.size, form.options.style
+    )
+
+    if product_exists:
+        # get currently updated product
+        s = session["items"][product_id]
+
+        if form.operation == "add":
+            # make sure the sum of user's existing cart's product
+            # quantity plus the newly requested quantity does not go
+            # over the product's available quantity (inventory amount)
+            if (s["quantity"] + quantity) <= inventory_amount:
+                s["quantity"] = s["quantity"] + 1
+
+        elif form.operation == "remove":
+            if s["quantity"] > 1:
+                s["quantity"] = s["quantity"] - 1
+
+            else:
+                del session["items"][product_id]
+
+        elif form.operation == "delete":
+            del session["items"][product_id]
+
+        elif form.operation == "manual":
+            if quantity > 0:
+                # cap manual input amount against available inventory amount
+                if quantity <= inventory_amount:
+                    s["quantity"] = quantity
+
+                else:
+                    # else round up to the inventory's amount value
+                    s["quantity"] = inventory_amount
+
+            else:
+                del session["items"][product_id]
+
+        # update item's availability
+        s["availability"] = inventory_amount > 0
+
+    else:
+        if form.operation == "add":
+            if quantity <= inventory_amount:
+                session["items"][product_id] = {
+                    "product_id": form.product_id,
+                    "options": {
+                        "size": form.options.size,
+                        "style": form.options.style,
+                    },
+                    "price": form.price,
+                    "quantity": 1,
+                    "availability": inventory_amount > 0,
+                }
+
+    update_cart_meta(session, shipping_amount)
+
+
+def clear_cart(session, currency: str) -> NoReturn:
+    """Clear cart from saved items.
+    Usually this is run after a successful payment.
+    """
+    order_id = shortuuid.uuid()
+    session["cart"] = {
+        "meta": {
+            "order_id": order_id,
+            "currency": currency,
+            "subtotal": 0,
+            "shipping": 0,
+            "total": 0,
+            "item_amount": 0,
+        },
+        "items": {},
+    }
+
+
+def calculate_quantity_requested_by_user(
+    session_cart_items: dict[str, dict[str, str | dict[str, str]]],
+    product_id: str,
+    size: str,
+    style: str,
+    quantity: int,
+) -> int:
+    """Helper function to retrieve the number of items of a specific
+    product that the user has in their cart. Used as an argument for
+    check_product_availability().
+    """
+    quantity_requested_by_user = 0
+
+    if len(session_cart_items.keys()) > 0:
+        cart_product_id = create_product_id(product_id, size, style)
+
+        if cart_product_id in session_cart_items:
+            quantity_requested_by_user = (
+                session_cart_items[cart_product_id]["quantity"] + quantity
+            )
+
+    return quantity_requested_by_user
+
+
+def create_product_list(settings: Settings, cart_items: dict[CartSessionItem]) -> str:
+    """
+    Return a string containing all the selected product in the user's
+    cart as a "comma-based list".
+    """
+
+    product_list = []
+    
+    for item in cart_items.values():
+        product = get_product_by_product_id(settings, item.product_id)
+
+        product_path = str(Path(product.meta.path).stem)
+        item = ProductInfoCode(path=product_path,
+                               options=item.options,
+                               quantity=item.quantity)
+
+        product_info = make_product_info_code(item)
+        product_list.append(product_info)
+
+
+    return ",".join(product_list)

+ 585 - 0
app/db.py

@@ -0,0 +1,585 @@
+import logging
+from pathlib import Path
+from typing import NoReturn
+
+import arrow
+import pycountry
+import yaml
+from openpyxl import load_workbook
+from pydantic import ValidationError
+
+from app.git import git_add_commit
+from app.parser import read_dir, read_file
+from app.schema import (
+    CartOrderInfo,
+    CartSession,
+    CartSessionItem,
+    DocumentCheckoutProductInfo,
+    DocumentProduct,
+    DocumentProductMeta,
+    OrdersBlock,
+    ShippingInfo,
+    create_product_id,
+    Settings,
+    ProductShippingMeta,    
+)
+from app.serializer import convert_pydantic_product_to_text, format_yaml_multiline, format_order_items, format_order_shipping_info
+
+logger = logging.getLogger(__name__)
+
+
+def check_product_availability(
+    product: DocumentProduct,
+    product_id: str,
+    quantity: int,
+    size: str | None,
+    style: str | None,
+    settings,
+) -> tuple[bool, bool, int]:
+    """Check if selected product, eventually with specified option size and style, is
+    available by checking the product's own inventory table. Returns a tuple of:
+    - is_product_available: do we have at least 1 copy of it in the inventory,
+    - is_product_selectable: can we show it in the web view or is it soldout,
+    - inventory_amount.
+    """
+    is_available = False
+    is_selectable = False
+    amount = 0
+
+    # we might receive a GET request from /products/<product-id> for a
+    # product with options but no option has been selected yet, cause
+    # the user just navigated to the page. in this case we can't make
+    # any filtering based on the options fields, so we just return the
+    # product to be available.
+    if product.meta.options and size is None and style is None:
+        is_available = True
+        is_selectable = True
+
+    else:
+        selected_inventory = [
+            inventory
+            for inventory in product.meta.inventory
+            if inventory.size == size and inventory.style == style
+        ]
+
+        if len(selected_inventory) > 0:
+            for model in selected_inventory:
+                if model.size == size and model.style == style:
+                    is_available = model.amount > 0
+
+                    # we check if the user's requested quantity for the given item is
+                    # between 1 and <= the model inventory's amount value
+                    is_selectable = (
+                        model.amount - quantity > 0 and quantity <= model.amount
+                    )
+                    amount = model.amount
+
+    return (is_available, is_selectable, amount)
+
+
+def update_product_inventory(
+    filename: str, quantity: int, size: str, style: str, settings
+) -> NoReturn:
+    """Read given Product document and update its inventory amount based
+    on the given product's size and style. Afterwards write back
+    document to disk.
+    """
+    doc = read_file(
+        settings.git_repo,
+        filename,
+        settings.document_match,
+        settings.block_types,
+        "products",
+    )
+
+    inventory = doc.meta.inventory
+
+    for model in inventory:
+        if model.size == size and model.style == style:
+            # run extra check to see if what would be decreased
+            # would amount to at best 0 (eg. not going negative)
+            if model.amount - quantity >= 0:
+                model.amount = model.amount - quantity
+
+    txt_doc = convert_pydantic_product_to_text(doc)
+
+    filepath = f"products/{filename}/index.md"
+    p = Path(f"{settings.git_repo}/{filepath}")
+
+    if p.exists():
+        with open(p, "w") as f:
+            f.write(txt_doc)
+
+        # update git repo
+        commit_msg = "Update product inventory"
+        git_add_commit(settings.git_repo, filepath, commit_msg)
+
+
+def get_products_by_category(settings):
+    """Helper function to product a list of products organized by their categories."""
+    products = read_dir(
+        settings.git_repo,
+        settings.document_match,
+        settings.document_exclude,
+        settings.block_types,
+        exclude_doc="products",
+        tree="products",
+    )
+
+    categories = {}
+    for p in products:
+        categories[p.meta.category] = []
+
+    for p in products:
+        block_img = [block for block in p.blocks if block.type == "image"][0]
+        p.blocks = []
+        p.blocks.append(block_img)
+
+        categories[p.meta.category].append(p)
+
+    return categories
+
+
+def get_product_by_product_id(
+    settings: Settings, product_id: str
+) -> DocumentProduct:
+    """Fetch a product by its product-id."""
+
+    products = read_dir(
+        settings.git_repo,
+        settings.document_match,
+        settings.document_exclude,
+        settings.block_types,
+        exclude_doc="products",
+        tree="products",
+    )
+
+    product = None
+
+    for p in products:
+        if p.meta.product_id == product_id:
+            product = p
+
+    return product
+
+
+def get_docs_by_category(category: str, settings):
+    """Read a directory of directories and return a sublist with only the
+    documents matching the given category.
+    """
+    pages = read_dir(
+        settings.git_repo,
+        settings.document_match,
+        settings.document_exclude,
+        settings.block_types,
+        exclude_doc=None,
+        tree="pages",
+    )
+
+    return [p for p in pages if p.meta.category == category]
+
+
+def is_order_id_open(settings: dict[str, str | list[str]], order_id: str) -> bool:
+    """Check whether given Order ID exists already in orders/index.md
+    and order status is not `Open`. Return appropriate boolean value.
+    """
+    filename = "orders"
+    orders = read_file(
+        settings["git_repo"],
+        filename,
+        settings["document_match"],
+        settings["block_types"],
+    )
+
+    if orders:
+        customer_order = [
+            order
+            for order in orders.blocks
+            if order.order_id == order_id and order.status != "Open"
+        ]
+
+        if len(customer_order) == 0:
+            return True
+        else:
+            return False
+
+
+def prepare_order_info(
+    order_meta: dict[str],
+    payment_meta: dict[str],
+    product_list: str,
+    cart: CartSession,
+    shipping_info: ShippingInfo,
+    products_meta: list[DocumentProductMeta],
+) -> str:
+    """Prepare text to save into local-db that sums up the received
+    order.
+    """
+    
+    # -- content order
+    cart_items = []
+
+    cm = cart.meta
+    ci = cart.items
+
+    if len(ci.keys()) > 0:
+        for k, v in ci.items():
+            for meta in products_meta:
+                cart_product_id = create_product_id(
+                    meta.product_id, v.options.size, v.options.style
+                )
+
+                if cart_product_id == k:
+                    item = CartOrderInfo(
+                        code=meta.code,
+                        title=meta.title,
+                        quantity=v.quantity,
+                        price=v.price * v.quantity,
+                        options=v.options,
+                    )
+
+                    cart_items.append(item)
+
+
+    items_table = format_order_items(cart_items)
+    shipping_info_table = format_order_shipping_info(shipping_info)
+
+    timestamp = arrow.now().format("YYYY-MM-DD HH:mm:ss ZZ")
+
+    return (
+        f"---\n"
+        f"type: 'order'\n"
+        f"date: {timestamp}\n"
+        f"order_id: '{order_meta['id']}'\n"
+        f"product_list: '{product_list}'\n"
+        f"payment_provider: '{payment_meta['provider']}'\n"
+        f"payment_reference: '{payment_meta['id']}'\n"
+        f"status: '{order_meta['status']}'\n"
+        f"items: \n{items_table}"
+        f"currency: '{cm.currency}'\n"
+        f"subtotal: '{cm.subtotal}'\n"
+        f"shipping: '{cm.shipping}'\n"
+        f"total: '{cm.total}'\n"
+        f"shipping_info: \n{shipping_info_table}"
+    )
+
+
+def prepare_customer_order_data(
+    git_repo: str,
+    document_match: list[str],
+    document_exclude: list[str],
+    block_types: list[str],
+    checkout_session_id: str,
+    product_list: str,
+    provider: str,
+    order_id: str,
+    cart: CartSession,
+    shipping_info: ShippingInfo,
+) -> dict[dict, str]:
+    """Prepare customer order data and git commit message."""
+    products = read_dir(
+        git_repo,
+        document_match,
+        document_exclude,
+        block_types,
+        exclude_doc="products",
+        tree="products",
+    )
+
+    products_meta = [product.meta for product in products]
+    order_meta = {"id": order_id, "status": "Open"}
+    payment_meta = {"id": checkout_session_id, "provider": provider}
+
+    customer_order = prepare_order_info(
+        order_meta, payment_meta, product_list, cart, shipping_info, products_meta
+    )
+
+    commit_msg = "Add order"
+
+    return {"data": customer_order, "commit_msg": commit_msg}
+
+
+def save_shipping_info(order: dict[str], git_repo: str, filepath: str) -> NoReturn:
+    """Save customer's shipping info data to local git repo."""
+    # write new text file under <filepath> with the customer's order
+
+    p = Path(f"{git_repo}/{filepath}")
+
+    with open(p, "a") as f:
+        f.write(order["data"])
+
+    git_add_commit(git_repo, filepath, order["commit_msg"])
+
+
+def update_order_info(
+    order_id: str, order_status: str, git_repo: str, filepath: str
+) -> bool:
+    """Update customer's order info after the payment has been processed."""
+    p = Path(f"{git_repo}/{filepath}")
+
+    try:
+        with open(p, "r+") as f:
+            order_content = f.read()
+            orders = yaml.safe_load_all(order_content)
+
+            updated_orders = []
+            for order in orders:
+                if order:
+                    if "type" in order and order["type"] == "meta":
+                        meta_block = f"---\n" f"{yaml.safe_dump(order)}"
+
+                        updated_orders.append(meta_block)
+
+                    elif "order_id" in order:
+                        if order["order_id"] == order_id:
+                            order["status"] = order_status
+
+                        # prepare each order entry by hand as pyyaml format things
+                        # in a way we don't like (ref multiline values).
+
+                        timestamp = arrow.get(order["date"]).format(
+                            "YYYY-MM-DD HH:mm:ss ZZ"
+                        )
+
+                        items_table = format_order_items(order['items'])
+                        shipping_info_table = format_order_shipping_info(ShippingInfo(**order['shipping_info']))
+
+                        updated_order = (
+                            f"---\n"
+                            f"type: 'order'\n"
+                            f"date: {timestamp}\n"
+                            f"order_id: '{order['order_id']}'\n"
+                            f"product_list: '{order['product_list']}'\n"
+                            f"payment_provider: '{order['payment_provider']}'\n"
+                            f"payment_reference: '{order['payment_reference']}'\n"
+                            f"status: '{order['status']}'\n"
+                            f"items: \n{items_table}"
+                            f"currency: '{order['currency']}'\n"
+                            f"subtotal: '{order['subtotal']}'\n"
+                            f"shipping: '{order['shipping']}'\n"
+                            f"total: '{order['total']}'\n"
+                            f"shipping_info: \n{shipping_info_table}"
+                        )
+
+                        updated_orders.append(updated_order)
+
+            new_orders = "".join(updated_orders)
+
+            f.seek(0)
+            f.write(new_orders)
+            f.truncate()
+
+            return True
+
+    except FileNotFoundError:
+        return False
+
+
+def update_status_order(
+    event_type: str, client_reference_id: str, git_repo: str, local_db_filepath: str
+) -> bool:
+    """Helper function to select order's status before updating it."""
+    status_index = {
+        "checkout.session.completed": "Complete",  # Stripe
+        "checkout.session.async_payment_succeeded": "Complete",  # Stripe
+        "checkout.session.async_payment_failed": "Failed",  # Stripe
+        "checkout.session.expired": "Expired",  # Stripe
+        "finished": "Complete",  # NOWPayments
+        "expired": "Expired",  # NOWPayments
+        "failed": "Failed",  # NOWPayments
+    }
+
+    order_status = status_index[event_type]
+
+    status_res = update_order_info(
+        client_reference_id, order_status, git_repo, local_db_filepath
+    )
+
+    if status_res:
+        commit_msg = "Update order status"
+        git_add_commit(git_repo, local_db_filepath, commit_msg)
+
+    return status_res
+
+
+def read_order_info_field(
+    field: str, order_id: str, git_repo: str, filepath: str
+) -> bool:
+    """Helper function to read the given field from the Order Info document as YAML."""
+    p = Path(f"{git_repo}/{filepath}")
+
+    try:
+        with open(p, "r+") as f:
+            order_content = f.read()
+            orders = yaml.safe_load_all(order_content)
+
+            selected_order = [
+                order
+                for order in orders
+                if "order_id" in order and order["order_id"] == order_id
+            ]
+
+            if len(selected_order) > 0:
+                selected_order = selected_order[0]
+
+                if field in selected_order:
+                    return selected_order[field]
+                else:
+                    None
+
+    except FileNotFoundError:
+        return None
+
+
+def pick_last_order_id(git_repo: str, filepath: str) -> str:
+    """Helper function to pick the ID from the last received order from the Order Info
+    document. Used for testing purposes.
+    """
+    p = Path(f"{git_repo}/{filepath}")
+
+    try:
+        with open(p, "r+") as f:
+            order_content = f.read()
+            orders = yaml.safe_load_all(order_content)
+
+            return list(orders)[-1]["order_id"]
+
+    except FileNotFoundError:
+        raise FileNotFoundError
+
+
+def get_product_values_from_checkout_string(
+    product_info: str,
+) -> list[str, int, str | None]:
+    """Helper function to parse a string part of the checkout operation, which contains:
+    - product path (eg. product slug)
+    - product quantity
+    - product's size and style
+    """
+    cart_product_id, product_quantity = product_info.split("--")
+    product_quantity = int(product_quantity)
+
+    filename, options = cart_product_id.split("__")
+
+    if options != "":
+        size, style = options.split("-")
+    else:
+        size = None
+        style = None
+
+    return filename, product_quantity, size, style
+
+
+def fetch_order_product_list(
+    client_reference_id: str, git_repo: str, local_db_filepath: str
+) -> list[DocumentCheckoutProductInfo] | None:
+    """Helper function to select order's status before updating it."""
+    order_product_info = read_order_info_field(
+        "product_list", client_reference_id, git_repo, local_db_filepath
+    )
+
+    if order_product_info is None:
+        logger.warning("fetch_order_product_list error: no order-product-info found.")
+        return None
+
+    product_list_index = []
+    product_list = order_product_info.split(",")
+    for product_info in product_list:
+        filename, quantity, size, style = get_product_values_from_checkout_string(
+            product_info
+        )
+
+        try:
+            product_info = DocumentCheckoutProductInfo(
+                filename=filename, quantity=quantity, size=size, style=style
+            )
+
+            product_list_index.append(product_info)
+
+        except ValidationError as e:
+            import json
+
+            logger.error("pydantic validation errors =>")
+            for e in e.errors():
+                logger.error(f"error => {json.dumps(e, indent=4)}")
+
+            logger.error("fetch_order_product_list error")
+
+    return product_list_index
+
+
+def export_order_to_xlsx(
+    order: OrdersBlock,
+    products_shipping_meta: ProductShippingMeta,
+    order_template_filepath: str,
+    currency: str,
+    git_repo,
+    email_from: str,
+):
+    """Export customer order to an .xlsx file, ready to be uploaded to
+    the SwissPost website as part of the shipping workflow.
+    """
+
+    # read from xslx template file (settings.order_upload.filepath)
+    # update spreadsheet in memory
+    # export it under `orders/upload/<order-id>.xlsx`
+
+    order_template_filepath = f"{git_repo}/{order_template_filepath}"
+
+    workbook = load_workbook(filename=order_template_filepath)
+    sheet = workbook.active
+
+    # -- add order info
+
+    order_country_iso = pycountry.countries.lookup(order.shipping_info.country).alpha_2
+
+    # invoice info
+    sheet["A2"] = order.order_id
+    sheet["B2"] = arrow.get(order.date).format("DD/MM/YY")
+    sheet["C2"] = currency
+    
+    sheet["D2"] = order.shipping_info.first_name
+    sheet["E2"] = order.shipping_info.last_name
+    sheet["H2"] = order.shipping_info.address        
+    sheet["I2"] = order.shipping_info.address_no
+    sheet["J2"] = order.shipping_info.postal_code
+    sheet["K2"] = order.shipping_info.city
+    sheet["M2"] = order_country_iso
+    sheet["N2"] = order.shipping_info.email
+    sheet["O2"] = order.shipping_info.phone_number
+
+    # recipient info
+    sheet["R2"] = order.shipping_info.first_name
+    sheet["S2"] = order.shipping_info.last_name
+    sheet["U2"] = order.shipping_info.address
+    sheet["V2"] = order.shipping_info.address_no
+    sheet["W2"] = order.shipping_info.postal_code
+    sheet["X2"] = order.shipping_info.city
+    sheet["Z2"] = order_country_iso
+    sheet["AA2"] = order.shipping_info.email
+    sheet["AB2"] = order.shipping_info.phone_number
+
+    # add order product items
+    for idx, item in enumerate(order.items):
+        # we set the baseline to row 2
+        row_n = 2 + idx
+
+        product = products_shipping_meta[idx]
+
+        sheet[f"AC{row_n}"] = item.code
+        sheet[f"AD{row_n}"] = item.title
+        sheet[f"AE{row_n}"] = item.quantity
+        sheet[f"AF{row_n}"] = item.price
+
+        sheet[f"AG{row_n}"] = product.weight
+        sheet[f"AH{row_n}"] = product.customs_tariff_code
+        sheet[f"AI{row_n}"] = product.origin_country_iso
+
+    # -- save file to disk
+    timestamp = arrow.get(order.date).format("YYYY-MM-DD-HHmmss")
+    filepath = f"{git_repo}/orders/upload/{timestamp}--{order.order_id}.xlsx"
+    workbook.save(filename=filepath)
+
+    # -- git add commit newly created file
+    commit_msg = f"Export order {order.order_id} to .xlsx format"
+    git_add_commit(git_repo, filepath, commit_msg)

+ 86 - 0
app/email.py

@@ -0,0 +1,86 @@
+import os
+from pathlib import Path
+from typing import NoReturn
+
+from fastapi import BackgroundTasks
+from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType
+from jinja2 import Environment, FileSystemLoader
+
+from app.parser import read_file
+
+
+def prepare_email(
+    git_repo: str, document_match: list[str], block_types: list[str], order
+) -> dict[str]:
+    """Prepare email message."""
+    filename = "checkout/email"
+    doc = read_file(
+        git_repo,
+        filename,
+        document_match,
+        block_types,
+        "",
+    )
+
+    # -- setup jinja template and expand data in it
+    environment = Environment(loader=FileSystemLoader(f"{Path(git_repo) / 'checkout'}"))
+    template = environment.get_template("email.md")
+    body = template.render(order)
+
+    # clean up data
+    block_marker = "\n---\n"
+    blocks = body.split(block_marker)
+    blocks = [block.strip() for block in blocks]
+
+    email_body = "\n\n---\n\n".join(blocks[1:])
+
+    return {
+        "sender": doc.meta.sender,
+        "subject": doc.meta.title,
+        "body": email_body,
+    }
+
+
+async def send_email(
+    settings: dict[str, list[str]],
+    order: dict[str],
+    background_tasks: BackgroundTasks | None = None,
+) -> NoReturn | tuple[FastMail, MessageSchema]:
+    """Send email to given customer. Async process."""
+
+    email_data = prepare_email(
+        settings["git_repo"], settings["document_match"], settings["block_types"], order
+    )
+
+    conf = ConnectionConfig(
+        MAIL_USERNAME=os.getenv("MAIL_USERNAME"),
+        MAIL_PASSWORD=os.getenv("MAIL_PASSWORD"),
+        MAIL_FROM=os.getenv("MAIL_FROM"),
+        MAIL_FROM_NAME=email_data["sender"],
+        MAIL_PORT=os.getenv("MAIL_PORT"),
+        MAIL_SERVER=os.getenv("MAIL_SERVER"),
+        MAIL_STARTTLS=os.getenv("MAIL_STARTTLS"),
+        MAIL_SSL_TLS=os.getenv("MAIL_SSL_TLS"),
+        USE_CREDENTIALS=os.getenv("MAIL_USE_CREDENTIALS"),
+        VALIDATE_CERTS=os.getenv("MAIL_VALIDATE_CERTS"),
+    )
+
+    recipients = []
+    if background_tasks:
+        recipients.append(order['email'])
+    else:
+        recipients.append(os.getenv("MAIL_TO_TEST"))
+
+    message = MessageSchema(
+        subject=email_data["subject"],
+        recipients=recipients,
+        body=email_data["body"],
+        subtype=MessageType.plain,
+    )
+
+    fm = FastMail(conf)
+
+    if background_tasks:
+        background_tasks.add_task(fm.send_message, message)
+    else:
+        return (fm, message)

+ 50 - 0
app/europe.json

@@ -0,0 +1,50 @@
+{
+  "source": "https://www.schengenvisainfo.com/countries-in-europe/",
+  "countries": [
+    "Albania",
+    "Andorra",
+    "Austria",
+    "Belarus",
+    "Belgium",
+    "Bosnia and Herzegovina",
+    "Bulgaria",
+    "Croatia",
+    "Czechia",
+    "Denmark",
+    "Estonia",
+    "Finland",
+    "France",
+    "Germany",
+    "Greece",
+    "Holy See",
+    "Hungary",
+    "Iceland",
+    "Ireland",
+    "Italy",
+    "Kosovo",
+    "Latvia",
+    "Liechtenstein",
+    "Lithuania",
+    "Luxembourg",
+    "Malta",
+    "Moldova",
+    "Monaco",
+    "Montenegro",
+    "Netherlands",
+    "North Macedonia",
+    "Norway",
+    "Poland",
+    "Portugal",
+    "Romania",
+    "Russia",
+    "San Marino",
+    "Serbia",
+    "Slovakia",
+    "Slovenia",
+    "Spain",
+    "Sweden",
+    "Switzerland",
+    "Ukraine",
+    "United Kingdom"
+  ]
+}

+ 74 - 0
app/form_validation.py

@@ -0,0 +1,74 @@
+from pydantic import BaseModel, EmailStr, field_validator
+import nh3
+import pycountry
+
+
+class FormCheckoutSession(BaseModel):
+    order_id: str
+    weight: int
+    email: EmailStr
+    first_name: str
+    last_name: str
+    address: str
+    address_no: str    
+    address_extra: str
+    postal_code: str
+    city: str
+    country: str
+    phone_number: str
+    note: str
+
+    @field_validator('order_id', 'first_name', 'last_name',
+                     'address', 'address_no', 'address_extra',
+                     'postal_code', 'city', 'country', 'note')
+    @classmethod
+    def check_xss(cls, v: str) -> str:
+        return nh3.clean(v)
+
+    @field_validator('weight')
+    @classmethod
+    def check_weight(cls, v: str) -> str:
+        if v <= 0:
+            raise ValueError('Weight should be a positive number.')
+        return v
+
+    @field_validator('country')
+    @classmethod
+    def check_country(cls, v: str) -> str:
+        countries = [country.name for country in pycountry.countries]
+
+        if v not in countries:
+            raise ValueError('Country is not part of valid list of countries.')
+        return v
+
+
+def form_validation_checkout_session(form_data):
+    """
+    Prepare dictionary with form data and validate it against a
+    specific ruleset that checks for:
+    - weight to be positive int
+    - email to be proper email format
+    - country to be part of allowed country
+    - any str for "XSS"
+    """
+
+    data = {
+        "order_id": form_data["order_id"],
+        "weight": form_data["weight"],
+        "email": form_data["email"],
+        "first_name": form_data["first_name"],
+        "last_name": form_data["last_name"],
+        "address": form_data["address"],
+        "address_no": form_data["address_no"],        
+        "address_extra": form_data["address_extra"],
+        "postal_code": form_data["postal_code"],
+        "city": form_data["city"],
+        "country": form_data["country"],
+        "phone_number": form_data["phone_number"],
+        "note": form_data["note"],
+    }
+
+
+    form = FormCheckoutSession(**data)
+
+    return form

+ 136 - 0
app/git.py

@@ -0,0 +1,136 @@
+import logging
+import subprocess
+import sys
+from pathlib import Path
+from typing import NoReturn
+
+logger = logging.getLogger(__name__)
+
+GIT_PATH = None
+
+try:
+    GIT_PATH = subprocess.check_output(["/usr/bin/which", "git"], text=True).strip()
+
+except subprocess.CalledProcessError:
+    # no git found in the system.
+    # stop everything and exit. print a user-facing
+    # message asking to install git.
+    logger.error(
+        "search_file_content error =>  git is not installed",
+    )
+    sys.exit(1)
+
+
+def is_git_repo(repo: str) -> bool:
+    """Spawn a shell and use git binary to test if current path is under git."""
+
+    def check_if_valid_repo(repo: str) -> bool:
+        is_repo = subprocess.check_output(
+            [GIT_PATH, "rev-parse", "--is-inside-work-tree"], text=True, cwd=repo
+        )
+
+        is_repo = is_repo.strip()
+
+        if is_repo == "true":
+            return True
+        elif is_repo == "false":
+            return False
+        else:
+            logger.error(f"is-git-repo error => {is_repo} not a valid path")
+            return False
+
+    repo = Path(repo)
+    if repo.exists():
+        if repo.is_dir():
+            return check_if_valid_repo(repo)
+
+        else:
+            logger.error(f"is-git-repo error => {repo} is not a directory")
+            repo = repo.parent
+
+            return check_if_valid_repo(repo)
+    else:
+        logger.error(f"is-git-repo error => {repo} is an invalid path")
+        return False
+
+
+def git_ls_files(
+    repo: str, pattern_matching: list[str], fullpath: bool = True
+) -> list[Path]:
+    """Return list of git-tracked filepaths from given repository path."""
+    filepaths = subprocess.check_output(
+        [GIT_PATH, "ls-files", "--", *pattern_matching], text=True, cwd=repo
+    )
+
+    filepaths = filepaths.strip().splitlines()
+
+    if fullpath:
+        filepaths = [Path(repo / filepath) for filepath in filepaths]
+
+    return filepaths
+
+
+def git_is_file_tracked(repo: str, filepath: str) -> bool:
+    """Check if given filepath is found by git ls-files -- <filepath> command."""
+    matched = subprocess.check_output(
+        [GIT_PATH, "ls-files", "--", filepath], text=True, cwd=repo
+    )
+
+    is_tracked = True if matched.strip() == filepath else False
+
+    return is_tracked
+
+
+def git_add_commit(repo: str, filepath: str, msg: str) -> NoReturn:
+    """Spawn a shell and use git binary to perform `git add` and `git commit`
+    on given <filepath>, and with specificed commit <msg>.
+    """
+    logger.info(f"git-add-commit... {repo, filepath, msg}")
+
+    r = subprocess.run(
+        f"{GIT_PATH} add '{filepath}' && {GIT_PATH} commit '{filepath}' -m '{msg}'",
+        shell=True,
+        cwd=repo,
+    )
+
+    logger.info(f"git-add-commit result => {r.returncode, r.stdout, r.stderr}")
+
+
+def git_log_last_commit(repo: str):
+    """Spawn a shell and run git binary to get the last commit from the
+    git log of the given <repo>.
+    """
+    filepaths = subprocess.check_output(
+        [GIT_PATH, "log", "-1", "--pretty=format:%cd"], text=True, cwd=repo
+    )
+
+    filepaths = filepaths.strip().splitlines()
+    filepaths = [Path(repo / filepath) for filepath in filepaths]
+
+    return filepaths
+
+
+def git_get_list_of_changed_filepaths_in_last_commit(
+    repo: str, oldrev: str, newrev: str, pattern: list[str]
+) -> tuple[str, str]:
+    """Return list of git modified filepaths, filtered by image type.
+
+    git diff --name-only <oldrev>..<newrev>
+    """
+    filepaths = subprocess.check_output(
+        [
+            GIT_PATH,
+            "diff",
+            "--name-only",
+            oldrev,
+            newrev,
+        ],
+        text=True,
+        cwd=repo,
+    )
+
+    filepaths = filepaths.strip().splitlines()
+
+    filepaths = [filepath for filepath in filepaths if Path(filepath).suffix in pattern]
+
+    return filepaths

+ 1163 - 0
app/main.py

@@ -0,0 +1,1163 @@
+import logging
+import os
+from pathlib import Path
+from typing import Literal
+from urllib.parse import urljoin, urlparse
+
+import stripe
+from asgi_csrf import asgi_csrf
+from dotenv import load_dotenv
+from fastapi import BackgroundTasks, FastAPI, Form, HTTPException, Request
+from fastapi.encoders import jsonable_encoder
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+from starlette.exceptions import HTTPException as StarletteHTTPException
+from starsessions import CookieStore, SessionAutoloadMiddleware, SessionMiddleware
+
+from pydantic import ValidationError, EmailStr
+
+import pycountry
+
+from app.cart import (
+    calculate_quantity_requested_by_user,
+    clear_cart,
+    initialize_cart,
+    product_exist_in_cart,
+    update_cart,
+    update_cart_meta,
+    create_product_list,
+)
+from app.db import (
+    check_product_availability,
+    export_order_to_xlsx,
+    fetch_order_product_list,
+    get_products_by_category,
+    is_order_id_open,
+    prepare_customer_order_data,
+    save_shipping_info,
+    update_product_inventory,
+    update_status_order,
+)
+from app.email import send_email
+from app.parser import md, read_dir, read_file
+from app.payment import (
+    calculate_shipping_cost,
+    prepare_shipping_rate,
+    generate_payment_link,
+    ipn_signature_check,
+    prepare_stripe_data,
+)
+from app.read_settings import read_settings
+from app.schema import CartSession, CartUpdate, NowPaymentsInvoice, ShippingInfo, ProductShippingMeta
+from app.template import (
+    make_product_selected,
+    prepare_checkout,
+    prepare_email_order,
+    prepare_product,
+    prepare_related_products,
+    prepare_sets_for_menu,
+    to_srcset,
+    assets_hashing,
+    make_product_info_code,
+    make_slugify
+)
+
+from app.form_validation import form_validation_checkout_session
+
+
+load_dotenv(".env")
+
+
+logging_level = logging.INFO
+main_logger = logging.getLogger()
+main_logger.setLevel(logging_level)
+formatter = logging.Formatter("%(asctime)s %(levelname)s: %(name)s => %(message)s")
+
+# Set up a stream handler to log to the console
+stream_handler = logging.StreamHandler()
+stream_handler.setLevel(logging_level)
+stream_handler.setFormatter(formatter)
+
+file_handler = logging.FileHandler("logs.log")
+file_handler.setLevel(logging_level)
+file_handler.setFormatter(formatter)
+
+# Add handlers to logger
+main_logger.addHandler(stream_handler)
+main_logger.addHandler(file_handler)
+
+
+app = FastAPI(docs_url=None, redoc_url=None)
+
+# -- read settings
+settings = read_settings("settings.toml")
+
+
+# -- mount static files
+app.mount(
+    "/static",
+    StaticFiles(directory=Path(__file__).parent.parent / "static"),
+    name="static",
+)
+
+# mount media folder from git content repo
+app.mount("/media", StaticFiles(directory=settings.git_repo), name="media")
+
+# -- setup templates
+templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
+templates.env.filters["md"] = md
+templates.env.filters["to_srcset"] = to_srcset
+templates.env.filters["assets_hashing"] = assets_hashing
+templates.env.filters["slugify"] = make_slugify
+
+templates.env.globals.update(CACHE=os.getenv('CACHE'))
+
+# -- add middlewares for CSRF and Session
+app.add_middleware(
+    asgi_csrf,
+    signing_secret=os.getenv("CSRF_SECRET_KEY"),
+    always_protect={"/", "/products", "/checkout"},
+)
+
+session_store = CookieStore(secret_key=os.getenv("SESSION_SECRET_KEY"))
+app.add_middleware(SessionAutoloadMiddleware)
+app.add_middleware(
+    SessionMiddleware,
+    store=session_store,
+    lifetime=3600 * 24 * 14,
+    cookie_https_only=os.getenv("ENV") == "dev",
+)
+
+
+# -- load Stripe and set API key
+if os.getenv("ENV") == "production":
+    stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
+else:
+    stripe.api_key = os.getenv("STRIPE_TEST_SECRET_KEY")
+
+
+@app.get("/", response_class=HTMLResponse)
+async def root(request: Request):
+    """The Main view of the website. It gives access to all the pages."""
+    session_data = request.session
+    initialize_cart(session_data, settings.currency.default)
+    cart = CartSession(**request.session["cart"])
+
+    doc = read_file(
+        settings.git_repo,
+        "index",
+        settings.document_match,
+        settings.block_types,
+        "",
+    )
+
+    categories = get_products_by_category(settings)
+    sets = prepare_sets_for_menu(settings)
+
+    site = read_file(
+        settings.git_repo,
+        "site",
+        settings.document_match,
+        settings.block_types,
+    )
+
+    response = templates.TemplateResponse(
+        "index.html",
+        {
+            "request": request,
+            "nav": None,
+            "doc": doc,
+            "categories": categories,
+            "sets": sets,
+            "site": site,
+            "cart": cart,
+        },
+    )
+
+    return response
+
+
+@app.get("/products", response_class=RedirectResponse, status_code=301)
+async def products():
+    """
+    The products view does not exists, so we return the user to the main view.
+    """
+
+    return "/"
+    
+
+@app.get("/products/{product_id}", response_class=HTMLResponse)
+async def product(
+    request: Request,
+    product_id: str,
+    js: bool = False,
+    size: str | None = None,
+    style: str | None = None,
+    quantity: int = 1,
+):
+    """The product view displaying the content of the selected product PRODUCT ID.
+    By default we check if the product exists in quantity = 1, unless
+    a different value is explicitly pass to the GET request.
+    """
+    session_data = request.session
+    initialize_cart(session_data, settings.currency.default)
+    cart = CartSession(**request.session["cart"])
+
+    tree = "products"
+    doc = read_file(
+        settings.git_repo,
+        product_id,
+        settings.document_match,
+        settings.block_types,
+        tree,
+    )
+
+    # we set quantity=0 because we are in a GET view and are not
+    # adding anything to the cart yet, just want to see if we could
+    # add 1 more item to it.
+    quantity_requested_by_user = calculate_quantity_requested_by_user(
+        session_data["cart"]["items"], doc.meta.product_id, size, style, quantity=0
+    )
+
+    triplet = check_product_availability(
+        doc, product_id, quantity_requested_by_user, size, style, settings
+    )
+
+    is_product_available, is_product_selectable, inventory_amount = triplet
+
+    if js:
+        product_selected = make_product_selected(size, style, quantity)
+
+        json_response = {
+            "is_product_available": is_product_available,
+            "is_product_selectable": is_product_selectable,
+            "product_selected": product_selected,
+        }
+
+        return JSONResponse(content=json_response, status_code=200)
+
+    else:
+        doc.blocks = prepare_product(doc)
+
+        size_guide = None
+        if doc.meta.options:
+            size_guide = read_file(
+                settings.git_repo,
+                "products",
+                settings.document_match,
+                settings.block_types,
+                None,
+            )
+
+        products = read_dir(
+            settings.git_repo,
+            settings.document_match,
+            settings.document_exclude,
+            settings.block_types,
+            exclude_doc=doc.meta.path,
+            tree="products",
+        )
+
+        related_products = prepare_related_products(products, product_id)
+
+        site = read_file(
+            settings.git_repo,
+            "site",
+            settings.document_match,
+            settings.block_types,
+        )
+
+        response = templates.TemplateResponse(
+            "product.html",
+            {
+                "request": request,
+                "nav": None,
+                "doc": doc,
+                "size_guide": size_guide,
+                "related_products": related_products,
+                "is_product_available": is_product_available,
+                "is_product_selectable": is_product_selectable,
+                "product_form": {
+                    "size": size,
+                    "style": style,
+                    "quantity": quantity,
+                },
+                "site": site,
+                "cart": cart,
+            },
+        )
+
+        return response
+
+
+@app.get("/pages", response_class=RedirectResponse, status_code=301)
+async def products():
+    """
+    The pages view does not exists, so we return the user to the main view.
+    """
+
+    return "/"
+    
+
+@app.get("/pages/{page_id}", response_class=HTMLResponse)
+async def page(request: Request, page_id: str):
+    """The Page view displaying the content of the selected PAGE ID."""
+    tree = "pages"
+    doc = read_file(
+        settings.git_repo,
+        page_id,
+        settings.document_match,
+        settings.block_types,
+        tree,
+    )
+
+    site = read_file(
+        settings.git_repo,
+        "site",
+        settings.document_match,
+        settings.block_types,
+    )
+
+    response = templates.TemplateResponse(
+        "page.html",
+        {
+            "request": request,
+            "nav": None,
+            "doc": doc,
+            "site": site,
+        },
+    )
+
+    return response
+
+
+@app.get("/checkout", response_class=HTMLResponse)
+async def checkout(request: Request):
+    """The Checkout view (GET, read-only)."""
+    filename = request.url.path[1:]
+    doc = read_file(
+        settings.git_repo, filename, settings.document_match, settings.block_types
+    )
+
+    products = read_dir(
+        settings.git_repo,
+        settings.document_match,
+        settings.document_exclude,
+        settings.block_types,
+        exclude_doc="products",
+        tree="products",
+    )
+
+    session_data = request.session
+    initialize_cart(session_data, settings.currency.default)
+
+    update_cart_meta(
+        session_data["cart"], session_data["cart"]["meta"]["shipping"], products
+    )
+
+    cart = CartSession(**request.session["cart"])
+    
+    checkout_data = prepare_checkout(cart, settings, doc, products)
+
+    site = read_file(
+        settings.git_repo,
+        "site",
+        settings.document_match,
+        settings.block_types,
+    )
+
+    response = templates.TemplateResponse(
+        "checkout.html",
+        {
+            "request": request,
+            "nav": None,
+            "data": checkout_data,
+            "site": site,
+            "cart": cart,
+        },
+    )
+
+    return response
+
+
+@app.post("/checkout")
+async def checkout_update(
+    request: Request,
+    js: bool = False,
+    page: Literal["product", "checkout"] = Form(...),
+    redirect_with_items: bool | None = Form(None),
+    operation: Literal["add", "remove", "delete", "manual"] = Form(...),
+    product_id: str = Form(...),
+    product_path: str = Form(...),
+    price: int = Form(...),
+    quantity: int = Form(...),
+    size: Literal["s", "m", "l", "xl", "xxl"] | None = Form(None),
+    style: str | None = Form(None),
+):
+    """The Checkout view, (POST, read-write). This view updates (add /
+    remove) the user's session cart. If the item is already present in
+    the cart, it increases or decreases the item quantity. Else, it
+    will be created or removed accordingly. It will also check if any
+    selected item in the cart is still available or it has sold out
+    meanwhile. Morever, it displays the Checkout view.
+    """
+    session_data = request.session
+    initialize_cart(session_data, settings.currency.default)
+
+    shipping_amount = session_data["cart"]["meta"]["shipping"]
+
+    if operation == "manual":
+        selected_product_id = session_data["cart"]["items"][product_id]
+        price = quantity * selected_product_id["price"]
+
+    tree = "products"
+    product_filename = Path(product_path).stem
+    selected_product = read_file(
+        settings.git_repo,
+        product_filename,
+        settings.document_match,
+        settings.block_types,
+        tree,
+    )
+
+    form = CartUpdate(
+        operation=operation,
+        product_id=product_id,
+        options={"size": size, "style": style},
+        price=price,
+    )    
+
+    quantity_requested_by_user = calculate_quantity_requested_by_user(
+        session_data["cart"]["items"], product_id, size, style, quantity
+    )
+
+    triplet = check_product_availability(
+        selected_product,
+        product_path,
+        quantity_requested_by_user,
+        size,
+        style,
+        settings,
+    )
+
+    is_product_available, is_product_selectable, inventory_amount = triplet
+
+    product_exists = product_exist_in_cart(
+        product_id, size, style, session_data["cart"]["items"]
+    )
+
+    update_cart(
+        session_data["cart"],
+        form,
+        product_exists,
+        shipping_amount,
+        quantity,
+        inventory_amount,
+    )
+
+    cart = CartSession(**request.session["cart"])
+
+    if js:
+        # -- return JSON res for js-handled form update
+
+        if page == "product":
+            cart_breakdown = jsonable_encoder(cart.meta)
+
+            product_selected = make_product_selected(size, style, quantity)
+
+            json_response = {
+                "cart_breakdown": cart_breakdown,
+                "is_product_available": is_product_available,
+                "is_product_selectable": is_product_selectable,
+                "product_selected": product_selected,
+            }
+
+        elif page == "checkout":
+            filename = request.url.path[1:]
+            doc = read_file(
+                settings.git_repo,
+                filename,
+                settings.document_match,
+                settings.block_types,
+            )
+
+            products = read_dir(
+                settings.git_repo,
+                settings.document_match,
+                settings.document_exclude,
+                settings.block_types,
+                exclude_doc="products",
+                tree="products",
+            )
+
+            checkout_data = prepare_checkout(
+                cart, settings, doc, products, js_update=False
+            )
+
+            cart_items = [
+                jsonable_encoder(item) for item in checkout_data["checkout_items"]
+            ]
+
+            cart_breakdown = jsonable_encoder(cart.meta)
+
+            json_response = {
+                "cart_items": cart_items,
+                "cart_breakdown": cart_breakdown,
+            }
+
+        return JSONResponse(content=json_response, status_code=200)
+
+    else:
+        redirect_URL = urljoin(
+            request.headers["referer"], urlparse(request.headers["referer"]).path
+        )
+        redirect_URL = f"{redirect_URL}"
+
+        if redirect_with_items:
+            URI_options = []
+            param_options = {"size": size, "style": style, "quantity": quantity}
+
+            for idx, option in enumerate(param_options.keys()):
+                if param_options[option]:
+                    param = f"{option}={param_options[option]}"
+                    URI_options.append(param)
+
+            if len(URI_options) > 0:
+                URI_params = "&".join(URI_options)
+                redirect_URL = f"{redirect_URL}?{URI_params}"
+
+            else:
+                redirect_URL = f"{redirect_URL}"
+
+        return RedirectResponse(redirect_URL, status_code=303)
+
+
+@app.post("/checkout/shipping")
+async def checkout_shipping(request: Request,
+                            js: bool = False,
+                            country: str = Form(...),
+                            weight: int = Form(...)):
+    """
+    This view returns the region to which the given submitted country belongs to.
+    This is used in /checkout to update shipping costs in real time.    
+    """
+
+    countries = [country.name for country in pycountry.countries]
+    if (
+            country not in countries
+            or weight <= 0
+    ):
+        raise HTTPException(status_code=403, detail="Shipping info are incorrect.")
+
+
+    shipping_rate = calculate_shipping_cost(country, weight, settings.currency.default)
+
+    if shipping_rate:
+
+        # update cart with new shipping costs
+        session_data = request.session
+        initialize_cart(session_data, settings.currency.default)
+
+        update_cart_meta(session_data["cart"], shipping_rate.price)
+        cart = CartSession(**request.session['cart'])
+
+        if js:
+            return cart.meta
+        else:
+            return RedirectResponse(f"{request.base_url}checkout", status_code=303)
+
+
+    raise HTTPException(status_code=403, detail="Shipping info are missing.")
+
+
+@app.post("/stripe-checkout-session", response_class=RedirectResponse, status_code=303)
+async def create_checkout_session(
+    request: Request,
+    order_id: str = Form(...),
+    weight: int = Form(...),
+    email: EmailStr = Form(...),
+    first_name: str | None = Form(None),
+    last_name: str = Form(...),
+    address: str = Form(...),
+    address_no: str = Form(...),        
+    address_extra: str | None = Form(None),
+    postal_code: str = Form(...),
+    city: str = Form(...),
+    country: str = Form(...),
+    phone_number: str = Form(None),
+    note: str | None = Form(None),
+):
+    """Stripe-based checkout session. Prepare order data and create a new
+    Stripe Checkout session. Handle gracefully in case of errors, or
+    if order exists already in local-db and has not `status: Open`.
+    """
+
+    # -- initial form-data validation
+    form_data = await request.form()
+
+    try:
+        form = form_validation_checkout_session(form_data)
+        
+    except ValidationError as e:
+        for error in e.errors():
+            main_logger.error(f"form-validation-stripe-checkout-session => {error}")
+
+
+    # -- check if order-id is unique
+    orders_settings = {
+        "git_repo": settings.git_repo,
+        "document_match": settings.document_match,
+        "block_types": settings.block_types,
+    }
+
+    if not is_order_id_open(orders_settings, form.order_id):
+        main_logger.error("stripe-checkout error: Order ID is wrong.")
+        raise HTTPException(status_code=422, detail="Order ID is wrong.")
+
+    shipping_info = {
+        "email": form.email,
+        "first_name": form.first_name,
+        "last_name": form.last_name,
+        "address": form.address,
+        "address_no": form.address_no,        
+        "address_extra": form.address_extra,
+        "postal_code": form.postal_code,
+        "city": form.city,
+        "country": form.country,
+        "phone_number": form.phone_number,
+        "note": form.note,
+    }
+
+    shipping_info = ShippingInfo(**shipping_info)
+
+    session_data = request.session
+    initialize_cart(session_data, settings.currency.default)
+
+    if "cart" not in session_data and len(session_data["cart"].keys()) == 0:
+        main_logger.error("stripe-checkout error: Cart is empty.")
+        raise HTTPException(status_code=403, detail="Cart is empty.")
+
+    cart = CartSession(**request.session["cart"])
+
+    try:
+        line_items = prepare_stripe_data(cart)
+
+        shipping_rate = prepare_shipping_rate(shipping_info.country, form.weight, settings.currency.default)
+        if shipping_rate is None:
+            main_logger.error("stripe-checkout error: Shipping info are missing.")
+            raise HTTPException(status_code=403, detail="Shipping info are missing.")
+
+        checkout_session = stripe.checkout.Session.create(
+            mode="payment",
+            line_items=line_items,
+            shipping_options=[shipping_rate],
+            customer_email=shipping_info.email,
+            client_reference_id=form.order_id,
+            success_url=f"{request.base_url}checkout/success",
+            cancel_url=f"{request.base_url}checkout",
+        )
+
+        product_list = create_product_list(settings, cart.items)
+
+        # -- save transaction to local-db
+        customer_order = prepare_customer_order_data(
+            settings.git_repo,
+            settings.document_match,
+            settings.document_exclude,
+            settings.block_types,
+            checkout_session["id"],
+            product_list,
+            "Stripe",
+            form.order_id,
+            cart,
+            shipping_info,
+        )
+
+        save_shipping_info(
+            customer_order, settings.git_repo, settings.local_db.filepath
+        )
+
+        # -- redirect to Stripe Checkout page
+        return checkout_session.url
+
+    except stripe.error.CardError as e:
+        main_logger.error(
+            f"stripe-checkout error: A payment error occurred: {e.user_message}"
+        )
+        raise HTTPException(status_code=400, detail="Stripe payment error.")
+
+    except stripe.error.InvalidRequestError as e:
+        main_logger.error(f"stripe-checkout error: An invalid request occurred. => {e}")
+        raise HTTPException(
+            status_code=400, detail="Stripe payment error (invalid request)."
+        )
+
+    except Exception as e:
+        main_logger.error(
+            f"stripe-checkout error: Another problem occurred, maybe unrelated to Stripe. {e}"
+        )
+        raise HTTPException(status_code=400, detail="Stripe payment error.")
+
+
+@app.post("/stripe-webhook", response_class=JSONResponse)
+async def stripe_webhook(request: Request, background_tasks: BackgroundTasks):
+    """Stripe webhook endpoint to receive updates from Stripe's checkout
+    operations.  Send email after successful order confirmation.
+    """
+    main_logger.info("stripe-webhook => ...")
+
+    event = None
+    payload = await request.body()
+    sig_header = request.headers["stripe-signature"]
+
+    try:
+        if os.getenv("ENV") == "production":
+            endpoint_secret = os.getenv("STRIPE_ENDPOINT_SECRET")
+        else:
+            endpoint_secret = os.getenv("STRIPE_TEST_ENDPOINT_SECRET")
+
+        event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
+
+    except ValueError as e:
+        main_logger.error(
+            f"stripe-webhook error: Webhook error while parsing basic request. {e}"
+        )
+        raise HTTPException(status_code=400, detail="Stripe payment error.")
+
+    except stripe.error.SignatureVerificationError as e:
+        main_logger.error(
+            f"stripe-webhook error: Webhook signature verification failed. {e}"
+        )
+        raise HTTPException(status_code=400, detail="Stripe payment error.")
+
+    if event:
+        checkout = event["data"]["object"]
+
+        if "client_reference_id" in checkout:
+            status_res = update_status_order(
+                event["type"],
+                checkout["client_reference_id"],
+                settings.git_repo,
+                settings.local_db.filepath,
+            )
+
+            if status_res is False:
+                main_logger.error("stripe-webhook error: order could not be updated.")
+                raise HTTPException(
+                    status_code=400, detail="Order could not be updated."
+                )
+
+            if event["type"] in [
+                "checkout.session.completed",
+                "checkout.session.async_payment_succeeded",
+            ]:
+                order_id = checkout["client_reference_id"]
+
+                product_list = fetch_order_product_list(
+                    order_id, settings.git_repo, settings.local_db.filepath
+                )
+
+                if product_list:
+                    for product_info in product_list:
+                        update_product_inventory(
+                            product_info.filename,
+                            product_info.quantity,
+                            product_info.size,
+                            product_info.style,
+                            settings,
+                        )
+
+                # -- send email
+                email_settings = {
+                    "git_repo": settings.git_repo,
+                    "document_match": settings.document_match,
+                    "block_types": settings.block_types,
+                }
+
+                order = prepare_email_order(order_id, email_settings)
+                await send_email(email_settings, order, background_tasks)
+
+            elif event["type"] == "checkout.session.async_payment_failed":
+                main_logger.error(
+                    f"stripe-webhook error: Stripe payment error. {event['type']}"
+                )
+                raise HTTPException(status_code=400, detail="Stripe payment error.")
+
+    else:
+        main_logger.error(f"stripe-webhook error: Unhandled event type {event['type']}")
+        raise HTTPException(status_code=400, detail="Stripe payment error.")
+
+    # -- return 200
+    return {"success": True}
+
+
+@app.post("/create-now-payments", response_class=RedirectResponse, status_code=303)
+async def create_now_payments(
+    request: Request,
+    order_id: str = Form(...),
+    weight: int = Form(...),
+    email: EmailStr = Form(...),
+    first_name: str | None = Form(None),
+    last_name: str = Form(...),
+    address: str = Form(...),
+    address_no: str = Form(...),        
+    address_extra: str | None = Form(None),
+    postal_code: str = Form(...),
+    city: str = Form(...),
+    country: str = Form(...),
+    phone_number: str = Form(None),        
+    note: str | None = Form(None),
+):
+    """NOWPayments checkout session. User selects with which currency to pay.
+    Sort of a custom NOW Payments Invoice page.
+    """
+
+    # -- initial form-data validation
+    form_data = await request.form()
+
+    try:
+        form = form_validation_checkout_session(form_data)
+        
+    except ValidationError as e:
+        for error in e.errors():
+            main_logger.error(f"form-validation-stripe-checkout-session => {error}")
+
+
+    # -- check if order-id is unique
+    orders_settings = {
+        "git_repo": settings.git_repo,
+        "document_match": settings.document_match,
+        "block_types": settings.block_types,
+    }
+
+    if not is_order_id_open(orders_settings, form.order_id):
+        main_logger.error("now-payments-checkout error: Order ID is wrong.")
+        raise HTTPException(status_code=422, detail="Order ID is wrong.")
+
+    shipping_info = {
+        "email": form.email,
+        "first_name": form.first_name,
+        "last_name": form.last_name,
+        "address": form.address,
+        "address_no": form.address_no,
+        "address_extra": form.address_extra,
+        "postal_code": form.postal_code,
+        "city": form.city,
+        "country": form.country,
+        "phone_number": form.phone_number,
+        "note": form.note,
+    }
+
+    shipping_info = ShippingInfo(**shipping_info)
+
+    session_data = request.session
+    initialize_cart(session_data, settings.currency.default)
+
+    if "cart" not in session_data and len(session_data["cart"].keys()) == 0:
+        main_logger.error("now-payments-checkout error: Cart is empty.")
+        raise HTTPException(status_code=403, detail="Cart is empty.")
+
+    cart = CartSession(**request.session["cart"])
+
+    data = {
+        "price_amount": cart.meta.total,
+        "price_currency": settings.currency.now_payments,
+        "order_id": form.order_id,
+        "ipn_callback_url": f"{request.base_url}now-webhook",
+        "success_url": f"{request.base_url}checkout/success",
+        "cancel_url": f"{request.base_url}checkout",
+    }
+
+    data = NowPaymentsInvoice(**data)
+    payment_url, np_order_id = await generate_payment_link(data)
+
+    product_list = create_product_list(settings, cart.items)    
+
+    # -- save transaction to local-db
+    customer_order = prepare_customer_order_data(
+        settings.git_repo,
+        settings.document_match,
+        settings.document_exclude,
+        settings.block_types,
+        np_order_id,
+        product_list,        
+        "NOW Payments",
+        data.order_id,
+        cart,
+        shipping_info,
+    )
+
+    save_shipping_info(customer_order, settings.git_repo, settings.local_db.filepath)
+
+    # -- redirect to NOWPayments Checkout page
+    return payment_url
+
+
+@app.post("/now-webhook")
+async def now_webhook(request: Request, background_tasks: BackgroundTasks):
+    """NOW Payments IPN (Instant Payment Notification) webhook.
+    Send email after successful payment confirmation.
+    """
+    main_logger.info("now-webhook...")
+
+    payload = await request.json()
+    sig_header = request.headers["x-nowpayments-sig"]
+
+    if os.getenv("ENV") == "production":
+        ipn_secret_key = os.getenv("NOWPAYMENTS_IPN")
+    else:
+        ipn_secret_key = os.getenv("NOWPAYMENTS_SANDBOX_IPN")
+
+    is_verified = ipn_signature_check(ipn_secret_key, sig_header, payload)
+
+    if is_verified:
+        # check payment status, update order if
+        # status has changed (finished, expired, failed)
+        # send email if status: finished.
+
+        event_type = payload["payment_status"]
+
+        if event_type in ["finished", "expired", "failed"]:
+            if "payment_id" in payload:
+                order_id = payload["order_id"]
+
+                status_res = update_status_order(
+                    event_type,
+                    order_id,
+                    settings.git_repo,
+                    settings.local_db.filepath,
+                )
+
+                if status_res is False:
+                    raise HTTPException(
+                        status_code=400, detail="Order could not be updated."
+                    )
+
+                if event_type == "finished":
+                    product_list = fetch_order_product_list(
+                        order_id, settings.git_repo, settings.local_db.filepath
+                    )
+
+                    if product_list:
+                        for product_info in product_list:
+                            update_product_inventory(
+                                product_info.filename,
+                                product_info.quantity,
+                                product_info.size,
+                                product_info.style,
+                                settings,
+                            )
+
+                    # -- send email
+                    email_settings = {
+                        "git_repo": settings.git_repo,
+                        "document_match": settings.document_match,
+                        "block_types": settings.block_types,
+                    }
+
+                    order = prepare_email_order(order_id, email_settings)
+                    await send_email(email_settings, order, background_tasks)
+
+                elif event_type == "failed":
+                    main_logger.error(
+                        f"(NowPayments) Unhandled event type {event_type}"
+                    )
+                    raise HTTPException(
+                        status_code=400, detail="NOWPayments payment error."
+                    )
+            else:
+                main_logger.error("(NowPayments) payment_id not in payload")
+                raise HTTPException(
+                    status_code=400, detail="NOWPayments payment error."
+                )
+
+
+@app.get("/checkout/success", response_class=HTMLResponse)
+async def checkout_success(request: Request):
+    """View that confirms to the user that the checkout procedure was successful."""
+    session_data = request.session
+
+    # if there's no cart session, redirect the user to `/`
+    if "cart" not in session_data:
+        clear_cart(session_data, settings.currency.default)
+        return RedirectResponse(f"{request.base_url}", status_code=303)
+
+    cart = CartSession(**request.session["cart"])
+
+    filename = "orders"
+    orders = read_file(
+        settings.git_repo, filename, settings.document_match, settings.block_types
+    )
+
+    customer_order = [
+        order for order in orders.blocks if order.order_id == cart.meta.order_id
+    ]
+
+    # if cart.meta.order_id does not match any order, redirect the user to `/`
+    if len(customer_order) == 0:
+        clear_cart(session_data, settings.currency.default)
+        return RedirectResponse(f"{request.base_url}", status_code=303)
+
+    # -- save data and clear cart, return view
+    data = {
+        "title": "Checkout Success",
+        "order_id": cart.meta.order_id,
+        "customer_email": customer_order[0].shipping_info.email,
+    }
+
+    # -- prepare order to xlsx format
+    product_list = fetch_order_product_list(
+                        cart.meta.order_id, settings.git_repo, settings.local_db.filepath
+                    )
+
+    if not product_list:
+        raise HTTPException(status_code=403, detail="Order checkout is missing information.")
+
+    products_shipping_meta = []
+    for product_info in product_list:
+        product = read_file(settings.git_repo, f"products/{product_info.filename}", settings.document_match, settings.block_types)
+        products_shipping_meta.append(
+            ProductShippingMeta(
+                weight=product.meta.weight,
+                customs_tariff_code=product.meta.customs_tariff_code,
+                origin_country_iso=product.meta.origin_country_iso,
+            )
+        )
+
+    email_from = os.getenv("MAIL_FROM")
+    export_order_to_xlsx(
+        customer_order[0],
+        products_shipping_meta,
+        settings.order_upload.filepath,
+        settings.currency.default,
+        settings.git_repo,
+        email_from,
+    )
+
+    clear_cart(session_data, settings.currency.default)
+    cart = CartSession(**request.session["cart"])
+
+    site = read_file(
+        settings.git_repo,
+        "site",
+        settings.document_match,
+        settings.block_types,
+    )
+
+    return templates.TemplateResponse(
+        "checkout-success.html",
+        {"request": request, "site": site, "data": data, "cart": cart},
+    )
+
+
+@app.get("/checkout/error", response_class=HTMLResponse)
+async def checkout_error(request: Request):
+    """View for when the checkout procedure fails."""
+    cart = CartSession(**request.session["cart"])
+
+    site = read_file(
+        settings.git_repo,
+        "site",
+        settings.document_match,
+        settings.block_types,
+    )
+
+    data = {
+        "title": "Checkout",
+    }
+
+    return templates.TemplateResponse(
+        "checkout-error.html",
+        {"request": request, "site": site, "data": data, "cart": cart},
+    )
+
+
+@app.post("/cart/clear")
+async def cart_clear(
+    request: Request,
+    js: bool = False,
+):
+    """Clear cart."""
+    request.session.clear()
+
+    return RedirectResponse(request.base_url, status_code=303)
+
+
+# -- set custom HTTP errors
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(request, exc):
+    """Handle invalid data exception by returning an HTML response
+    rather than a JSON one.
+    """
+    site = read_file(
+        settings.git_repo,
+        "site",
+        settings.document_match,
+        settings.block_types,
+    )
+    message = "We can't process your request."
+
+    main_logger.error(exc)
+
+    t = templates.TemplateResponse(
+        "error.html",
+        {
+            "request": request,
+            "site": site,
+            "data": {
+                "title": "Error",
+                "error": message,
+                "status_code": 422,
+            },
+            "cart": None,
+        },
+    )
+
+    return HTMLResponse(content=t.body, status_code=422)
+
+
+@app.exception_handler(StarletteHTTPException)
+async def http_exception_handler(request, exc):
+    """Custom handling of HTTP exception (400-500).
+    Prepare an appropriate user-facing message and pass it
+    to the error.html template.
+    """
+    message = ""
+
+    main_logger.error(f"http-exception: error => {exc}")
+
+    if exc.status_code == 404:
+        message = "Nothing was found here."
+    elif exc.status_code == 500:
+        message = "Server error."
+    elif 400 <= exc.status_code <= 499:
+        message = "Something went wrong."
+        if exc.detail:
+            message = exc.detail
+
+    site = read_file(
+        settings.git_repo,
+        "site",
+        settings.document_match,
+        settings.block_types,
+    )
+
+    data = {
+        "title": "Error",
+        "error": message,
+        "status_code": exc.status_code,
+    }
+
+    t = templates.TemplateResponse(
+        "error.html",
+        {
+            "request": request,
+            "site": site,
+            "data": data,
+            "cart": None,
+        },
+    )
+
+    return HTMLResponse(content=t.body, status_code=exc.status_code)

+ 342 - 0
app/parser.py

@@ -0,0 +1,342 @@
+import logging
+import re
+from pathlib import Path
+
+import cv2
+import yaml
+from fastapi import HTTPException
+from markdown_it import MarkdownIt
+from mdit_py_plugins.anchors import anchors_plugin as section_header
+from pydantic import ValidationError
+
+from app.git import git_is_file_tracked, git_ls_files
+from app.schema import (
+    DocumentCheckout,
+    DocumentEmail,
+    DocumentIndex,
+    DocumentOrders,
+    DocumentPage,
+    DocumentProduct,
+    DocumentProductIndex,
+    DocumentSite,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def md(text: str) -> str:
+    """Helper function to convert parsed markdown-formatted text to HTML."""
+    md = (
+        MarkdownIt("commonmark", {"breaks": True, "html": True})
+        .enable("table")
+        .use(section_header)
+    )
+
+    return md.render(text)
+
+
+def set_default_product_meta_fields(block) -> dict[str, str]:
+    """
+    Helper function to make sure all necessary fields expected by the
+    document schema are present in dictionary. This prevents errors
+    when in the text document some keys have been omitted (because
+    unnecessary / empty), but are needed by the document scheme.
+    """
+
+    if block['template'] == 'product':
+        
+        if 'code' not in block:
+            block['code'] = ''
+        elif 'product_id' not in block:
+            block['product_id'] = ''
+        elif 'price' not in block:
+            block['price'] = 0
+        elif 'weight' not in block:
+            block['weight'] = 0
+        elif 'customs_tariff_code' not in block:
+            block['customs_tariff_code'] = ''
+        elif 'origin_country_iso' not in block:
+            block['origin_country_iso'] = ''
+        elif 'category' not in block:
+            block['category'] = ''
+
+
+        if 'options' in block and len(block['options']) > 0:
+            if 'size' not in block['options']:
+                block['options']['size'] = None
+            if 'style' not in block['options']:
+                block['options']['style'] = None
+        else:
+            block['options'] = {
+                'size':None,
+                'style': None,
+            }
+
+        if 'inventory' in block and len(block['inventory']) > 0:
+            for item in block['inventory']:
+                if 'size' not in item:
+                    item['size'] = None
+                if 'style' not in item:
+                    item['style'] = None                   
+                if 'amount' not in item:
+                    item['amount'] = 0
+
+        else:
+            block['inventory'] = [
+                {
+                    'size': None, 'style': None, 'amount': 0
+                }
+            ]
+
+                    
+    return block
+
+
+def make_block_md(block: str) -> dict[str]:
+    """Helper function to prepare a dictionary object for a block of type
+    text. Block texts are free-form strings and not formatted with
+    YAML, so we prepare the dictionary here.
+    """
+    block.strip()
+
+    if block != "":
+        return {"value": block, "type": "text"}
+
+
+def make_block_image(
+    repo_path: str, meta_path: str, block: dict[str, str]
+) -> dict[str, int]:
+    """Helper function to enrich the image block with a few more info
+    about the file it points to. We make use of this when displaying
+    the image in the frontend template and prepare the correct srcset
+    list of sizes we can display for the given image.
+    """
+
+    filepath = None
+    if (meta_path != '.'):
+        filepath = f"{repo_path}/{meta_path}/{block['url']}"
+    else:
+        filepath = f"{repo_path}/{block['url']}"
+
+    img = cv2.imread(filepath)
+
+    if img is not None and img.any():
+
+        if 'caption' not in block:
+            block['caption'] = ""
+
+        block["info"] = {
+            "width": round(img.shape[1]),
+            "height": round(img.shape[0]),
+        }
+
+        return block
+
+    else:
+        return {
+            "type": "image",
+            "value": "",
+            "url": "",
+            "caption": "",
+            "info": {
+                "width": 0,
+                "height": 0
+            },
+        }
+
+
+def parse_file(
+    repo_path: str, input_path: str | Path, block_types: list[str]
+) -> (
+    DocumentSite
+    | DocumentIndex
+    | DocumentPage
+    | DocumentProductIndex
+    | DocumentProduct
+    | DocumentCheckout
+    | DocumentOrders
+    | DocumentEmail
+):
+    """Parse file at given filepath: return a dictionary with meta
+    dictionary and list of blocks.
+    """
+    document = {
+        "meta": {
+            "type": "",
+            "title": "",
+            "template": "",
+            "path": "",
+        },
+        "blocks": [],
+    }
+
+    try:
+        p = Path(input_path)
+    except KeyError:
+        document["meta"]["path"] = ""
+
+    data = input_path.read_text()
+
+    block_marker = "\n---\n"
+    blocks = data.split(block_marker)
+    blocks = [re.sub(r"^---\n", "", block) for block in blocks]
+
+    # set meta_path to default value
+    meta_path = document["meta"]["path"]
+
+    for block in blocks:
+        # we check if current block is a potential
+        # yaml block, then check if any other block
+        # has a field type in the ones "allowed".
+        # in case the block.type has no matches
+        # or it fails the yaml parser, we take it
+        # as a markdown block
+
+        try:
+            block_yaml = yaml.safe_load(block)
+
+            if block_yaml is not None:
+                if type(block_yaml) is dict:
+                    # check if "valid" YAML dict is actually
+                    # a long string with a colon in the middle;
+                    # somehow this trips up the YAML parser and
+                    # we need to take care of it ourselves
+                    for k in block_yaml.keys():
+                        if len(k.split()) > 1 and ":" in k:
+                            block_txt = make_block_md(block)
+                            document["blocks"].append(block_txt)
+
+                    if "type" in block_yaml:
+                        if block_yaml["type"] == "meta":
+                            document["meta"] = set_default_product_meta_fields(block_yaml)
+
+                            for k, v in block_yaml.items():
+                                document["meta"][k] = v
+
+                            # set meta-path
+                            if "template" in block_yaml:
+                                doc_path = Path(p).relative_to(repo_path).parent
+                                document["meta"]["path"] = str(doc_path)
+
+                            meta_path = document["meta"]["path"]
+
+                        elif (
+                            "type" in block_yaml
+                            and block_yaml["type"].lower() in block_types
+                        ):
+                            if block_yaml["type"] == "image":
+                                make_block_image(repo_path, meta_path, block_yaml)
+
+                            document["blocks"].append(block_yaml)
+
+                else:
+                    block_md = make_block_md(block)
+                    document["blocks"].append(block_md)
+
+        except yaml.YAMLError:
+            block_md = make_block_md(block)
+            document["blocks"].append(block_md)
+    try:
+        if document["meta"]["template"] == "site":
+            return DocumentSite(**document)
+
+        elif document["meta"]["template"] == "index":
+            return DocumentIndex(**document)
+
+        elif document["meta"]["template"] == "page":
+            return DocumentPage(**document)
+
+        elif document["meta"]["template"] == "product-index":
+            return DocumentProductIndex(**document)
+
+        elif document["meta"]["template"] == "product":
+            return DocumentProduct(**document)
+
+        elif document["meta"]["template"] == "checkout":
+            return DocumentCheckout(**document)
+
+        elif document["meta"]["template"] == "orders":
+            return DocumentOrders(**document)
+
+        elif document["meta"]["template"] == "email":
+            return DocumentEmail(**document)
+
+    except ValidationError as e:
+
+        logger.error("pydantic validation errors =>")
+        for e in e.errors():
+            logger.error(f"error => {e}")
+
+        raise FileNotFoundError
+
+
+def read_dir(
+    repo_path: str,
+    document_match: list[str],
+    document_exclude: list[str],
+    block_types: list[str],
+    exclude_doc: str | None = None,
+    tree: str | None = None,
+):
+    """Run git ls-files with appropriate pattern matching to return list
+    of desired git-tracked filepaths.
+    """
+    d = Path(repo_path).expanduser()
+
+    pattern_matching = []
+    [pattern_matching.append(f"*{ext}") for ext in document_match]
+    [pattern_matching.append(f":!:{ext}") for ext in document_exclude]
+
+    paths = git_ls_files(d, pattern_matching)
+    paths = [p for p in paths if tree in str(p)]
+
+    docs = []
+    for p in paths:
+        doc = parse_file(d, p, block_types)
+
+        if doc.meta.path != exclude_doc:
+            docs.append(doc)
+
+    return docs
+
+
+def read_file(
+    repo_path: str,
+    filename: str,
+    file_match: list[str],
+    block_types: list[str],
+    tree: str | None = None,
+):
+    """Helper function to read file from disk. Check if filepath's
+    extension is allowed (by settings.toml) and filepath is tracked by
+    git, then read its content.
+    """
+    filepath = None
+    ext = file_match[0]
+
+    if tree:
+        p = f"{Path(repo_path)}/{tree}/{filename}"
+    else:
+        p = f"{Path(repo_path)}/{filename}"
+
+    if Path(p).is_dir():
+        file_path = f"{p}/index{ext}"
+
+        if Path(file_path).is_file():
+            filepath = file_path
+        else:
+            raise FileNotFoundError
+
+    elif Path(f"{p}{ext}").is_file():
+        filepath = f"{p}{ext}"
+
+    if filepath:
+        # get file path relative to the content repo
+        # eg <path/to/repo/dir/file> => <dir>/<file>
+        filename = str(Path(filepath).relative_to(repo_path))
+        if git_is_file_tracked(repo_path, filename):
+            doc = parse_file(repo_path, Path(filepath), block_types)
+            return doc
+
+    else:
+        raise HTTPException(status_code=404, detail="Nothing was found here.")

+ 182 - 0
app/payment.py

@@ -0,0 +1,182 @@
+import hashlib
+import hmac
+import json
+import logging
+import os
+from typing import NoReturn
+from pathlib import Path
+
+import httpx
+from fastapi import HTTPException
+from fastapi.encoders import jsonable_encoder
+
+from pydantic import ValidationError
+from app.schema import CartSession, NowPaymentsInvoice, ShippingCostEntry, ShippingRate
+
+logger = logging.getLogger(__name__)
+
+
+def prepare_stripe_data(cart: CartSession) -> list[str, int]:
+    """Returns list of items ready for Stripe to be processed.
+    Each item is in the form of {price: <price_id>, quantity: <amount>}
+    list_items = [
+        {
+            # Provide the exact Price ID (for example, pr_1234) of the product you want to sell
+            'price': ,
+            'quantity': 0
+        }
+    ]
+    """
+    list_items = []
+
+    for item in cart.items.values():
+        item = {
+            "price": item.product_id,
+            "quantity": item.quantity,
+        }
+
+        list_items.append(item)
+
+    return list_items
+
+
+def is_country_in_europe(country: str) -> bool | NoReturn:
+    """
+    Check if given country is part of a list of countries in Europe.
+    """
+
+    dir_path = Path(__file__).resolve().parent
+    europe_db_filepath = Path(f"{dir_path}/europe.json")
+
+    try:
+        with open(europe_db_filepath, encoding='utf-8') as file:
+            europe_db = json.load(file)
+
+            if country in europe_db['countries']:
+                return True
+
+            return False
+
+    except FileNotFoundError as error:
+        logger.error(f"is-country-in-europe => {error}")
+        
+
+def calculate_shipping_cost(country: str, weight: int, currency: str):
+    """Returns appropriate shipping price based on given Country and product's Weight."""
+
+    dir_path = Path(__file__).resolve().parent
+    shipping_cost_db_filepath = Path(f"{dir_path}/shipping-cost.json")
+
+    try:
+        with open(shipping_cost_db_filepath, encoding='utf-8') as f:
+            shipping_cost_db = json.load(f)
+
+            area = None
+            if country == "Switzerland":
+                area = country
+                
+            elif is_country_in_europe(country):
+                area = "Europe"
+                
+            else:
+                area = "International"
+            
+
+            shipping_rate = None
+            for entry in shipping_cost_db['areas'][area]:
+
+                try:
+                    entry = ShippingCostEntry(**entry)
+
+                    if (
+                            entry.max is None and weight >= entry.min or
+                            entry.min <= weight <= entry.max
+                    ):
+
+                        shipping_rate = ShippingRate(price=entry.price, area=area)
+
+                
+                except ValidationError as e:
+                    logger.error("pydantic validation errors =>")
+                    for e in e.errors():
+                        logger.error(f"error => {e}")
+                
+
+            return shipping_rate
+
+
+    except FileNotFoundError as error:
+        logger.error(f"calculate-shipping-cost => {error}")
+
+        
+
+def prepare_shipping_rate(country: str, weight: int, currency: str):
+    """
+    """
+
+    shipping_rate = calculate_shipping_cost(country, weight, currency)
+
+    # Stripe needs shipping price set in cents as an integer
+    shipping_rate.price = int(shipping_rate.price * 100)
+
+    return {
+        "shipping_rate_data": {
+            "type": "fixed_amount",
+            "fixed_amount": {"amount": shipping_rate.price, "currency": currency},
+            "display_name": shipping_rate.area,
+        }
+    }
+
+
+async def generate_payment_link(data: NowPaymentsInvoice) -> tuple[str, str] | NoReturn:
+    """Generate payment link for NOW Payments crypto payment service.
+    See <https://documenter.getpostman.com/view/7907941/2s93JusNJt#f5e4e645-dce2-4b06-b2ca-2a29aaa5e845>.
+    """
+    environment = os.getenv("ENV")
+
+    API_URL = "https://api.nowpayments.io/v1/"
+    NOW_API_KEY = os.getenv("NOWPAYMENTS_API_KEY")
+
+    if environment != "production":
+        API_URL = "https://api-sandbox.nowpayments.io/v1/"
+        NOW_API_KEY = os.getenv("NOWPAYMENTS_SANDBOX_API_KEY")
+
+    ENDPOINT = "invoice"
+
+    headers = {"x-api-key": NOW_API_KEY}
+    async with httpx.AsyncClient(headers=headers) as client:
+        resp = await client.post(f"{API_URL}{ENDPOINT}", json=jsonable_encoder(data))
+
+        try:
+            resp.raise_for_status()
+
+            if resp.status_code == 200:
+                data = resp.json()
+                return data["invoice_url"], data["id"]
+
+        except httpx.HTTPStatusError as exc:
+            logger.error(f"Error {exc.response.status_code}: {exc.response.text}")
+            raise HTTPException(
+                status_code=400, detail="NOWPayments link cannot be generated."
+            )
+
+
+def ipn_signature_check(
+    np_secret_key: str, np_x_signature: str, payload: dict[str]
+) -> bool:
+    """Signature verification function as part of the NOW Paymnents's
+    Instant Payment Notifications (IPN) webhook workflow.
+    See <https://documenter.getpostman.com/view/7907941/2s93JusNJt>.
+    """
+    sorted_msg = json.dumps(payload, separators=(",", ":"), sort_keys=True)
+
+    digest = hmac.new(
+        str(np_secret_key).encode(), f"{sorted_msg}".encode(), hashlib.sha512
+    )
+
+    signature = digest.hexdigest()
+
+    if signature == np_x_signature:
+        return True
+    else:
+        return False

+ 35 - 0
app/read_settings.py

@@ -0,0 +1,35 @@
+import logging
+import sys
+from pathlib import Path
+from typing import NoReturn
+
+import tomli
+
+from app.git import is_git_repo
+from app.schema import Settings
+
+logger = logging.getLogger(__name__)
+
+
+def read_settings(filepath: str, check_valid_repo: bool = True) -> Settings | NoReturn:
+    """Read settings.toml from the root of the project folder"""
+    try:
+        with open(filepath, mode="rb") as f:
+            config = tomli.load(f)
+
+            if check_valid_repo:
+                git_repo = str(Path(config["git_repo"]).expanduser())
+                if not is_git_repo(str(git_repo)):
+                    raise FileNotFoundError("git-repo has not been found")
+
+                config["git_repo"] = git_repo
+
+            settings = Settings(**config)
+            return settings
+
+    except FileNotFoundError:
+        logger.error(
+            f"error => {filepath} has not been found\n",
+            "        please check the README for instructions",
+        )
+        sys.exit(1)

+ 355 - 0
app/schema.py

@@ -0,0 +1,355 @@
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel
+
+
+# -- settings --
+class SettingsLocalDB(BaseModel):
+    filepath: str
+
+
+class SettingsOrderUpload(BaseModel):
+    filepath: str
+
+
+class SettingsCurrency(BaseModel):
+    default: str
+    now_payments: str
+
+
+class Settings(BaseModel):
+    git_repo: str
+    document_match: list[str]
+    document_exclude: list[str]
+    block_types: list[str]
+    local_db: SettingsLocalDB
+    order_upload: SettingsOrderUpload
+    currency: SettingsCurrency
+
+
+# -- cart --
+
+# session-related data structures
+
+
+class CartSessionMeta(BaseModel):
+    order_id: str
+    currency: str
+    subtotal: int = 0
+    shipping: float = 0.0
+    total: float = 0.0
+    item_amount: int = 0
+
+
+class CartSessionItemOptions(BaseModel):
+    size: Literal["s", "m", "l", "xl", "xxl"] | None
+    style: str | None
+
+
+class CartSessionItem(BaseModel):
+    product_id: str
+    options: CartSessionItemOptions
+    price: int = 0
+    quantity: int = 0
+    availability: bool = False
+
+
+class CartSession(BaseModel):
+    meta: CartSessionMeta
+    items: dict[str, CartSessionItem]
+
+
+class CartUpdate(BaseModel):
+    operation: Literal["add", "remove", "delete", "manual"]
+    product_id: str
+    options: CartSessionItemOptions
+    price: int = 0
+
+
+# view-related data structures
+class CheckoutItemImage(BaseModel):
+    url: str
+    width: int
+    height: int
+
+
+class CheckoutItem(CartSessionItem):
+    path: str
+    title: str
+    image: CheckoutItemImage
+    product_id: str
+    options: CartSessionItemOptions
+    price: int = 0
+    weight: int
+    quantity: int = 0
+    total: int = 0
+    availability: bool = False
+    is_selectable: bool = False
+
+
+class CheckoutItemUpdate(BaseModel):
+    quantity: int = 0
+    total: int = 0
+    is_selectable: bool = False
+
+
+class CartOrderInfo(BaseModel):
+    code: str
+    title: str
+    price: int = 0
+    quantity: int = 0
+    options: CartSessionItemOptions
+
+
+class ProductInfoCode(BaseModel):
+    path: str
+    options: CartSessionItemOptions
+    quantity: int
+
+
+# -- shipping info
+class ShippingInfo(BaseModel):
+    email: str
+    first_name: str | None = None
+    last_name: str
+    address: str
+    address_no: str    
+    address_extra: str | None = None
+    postal_code: str
+    city: str
+    country: str
+    phone_number: str | None = None
+    note: str | None = None
+
+
+# -- shipping cost
+class ShippingCostEntry(BaseModel):
+    price: float
+    min: int
+    max: int | None
+
+class ShippingRate(BaseModel):
+    price: float
+    area: str
+
+
+class ProductShippingMeta(BaseModel):
+    weight: int
+    customs_tariff_code: str
+    origin_country_iso: str    
+
+
+# -- documents --
+class DocumentBlockImageInfo(BaseModel):
+    width: int
+    height: int
+
+
+class DocumentBlock(BaseModel):
+    type: str
+    value: str | None = None
+    url: str | None = None
+    caption: str | None = None
+    info: DocumentBlockImageInfo | None = None
+
+
+# -- document: site
+class DocumentMetaLink(BaseModel):
+    label: str
+    url: str
+
+
+class DocumentSiteMeta(BaseModel):
+    type: str
+    title: str
+    template: str
+    path: str | None = None
+    footer: list[DocumentMetaLink]
+
+
+class DocumentSite(BaseModel):
+    meta: DocumentSiteMeta
+    blocks: list[DocumentBlock]
+
+
+# -- document: index
+class DocumentIndexMeta(BaseModel):
+    type: str
+    title: str
+    template: str
+    path: str | None = None
+
+
+class DocumentIndex(BaseModel):
+    meta: DocumentIndexMeta
+    blocks: list[DocumentBlock]
+
+
+# -- document: page --
+class DocumentPageMeta(BaseModel):
+    type: str
+    title: str
+    template: str
+    category: str | None = None
+    path: str | None = None
+
+
+class DocumentPage(BaseModel):
+    meta: DocumentPageMeta
+    blocks: list[DocumentBlock]
+
+
+# -- document: products index --
+class DocumentProductIndexMeta(BaseModel):
+    type: str
+    title: str
+    template: str
+    path: str | None = None
+
+
+class DocumentProductIndex(BaseModel):
+    meta: DocumentProductIndexMeta
+    blocks: list[DocumentBlock]
+
+
+# -- document: product
+class DocumentProductMetaOptions(BaseModel):
+    size: list[str] | None = None
+    style: list[str] | None = None
+
+
+class DocumentProductMetaInventory(BaseModel):
+    size: str | None = None
+    style: str | None = None
+    amount: int
+
+
+class DocumentProductMeta(BaseModel):
+    type: str
+    title: str
+    template: str
+    code: str
+    product_id: str
+    price: int
+    weight: int
+    customs_tariff_code: str
+    origin_country_iso: str
+    category: str
+    inventory: list[DocumentProductMetaInventory]
+    options: DocumentProductMetaOptions | None = None
+    path: str | None = None
+
+
+class DocumentProduct(BaseModel):
+    meta: DocumentProductMeta
+    blocks: list[DocumentBlock]
+
+
+# -- document: orders
+class DocumentOrdersMeta(BaseModel):
+    type: str
+    title: str
+    template: str
+    path: str | None = None
+
+
+class OrdersBlockItem(BaseModel):
+    code: str
+    title: str
+    quantity: int
+    price: int
+
+
+class OrdersBlockShippingInfo(BaseModel):
+    first_name: str | None
+    last_name: str
+    address: str
+    address_no: str
+    address_extra: str | None
+    postal_code: str
+    city: str
+    country: str
+    note: str | None
+    phone_number: str | None
+    email: str
+
+
+class OrdersBlock(BaseModel):
+    type: str
+    date: datetime
+    order_id: str
+    product_list: str
+    payment_provider: str
+    payment_reference: str
+    status: str
+    items: list[OrdersBlockItem]
+    currency: str
+    subtotal: int
+    shipping: float
+    total: float
+    shipping_info: OrdersBlockShippingInfo
+
+
+class DocumentOrders(BaseModel):
+    meta: DocumentOrdersMeta
+    blocks: list[OrdersBlock]
+
+
+# -- document: checkout
+class DocumentCheckoutMeta(BaseModel):
+    type: str
+    title: str
+    template: str
+    path: str | None = None
+
+
+class DocumentCheckout(BaseModel):
+    meta: DocumentCheckoutMeta
+    blocks: list[DocumentBlock]
+
+
+# -- document: checkout product info
+class DocumentCheckoutProductInfo(BaseModel):
+    filename: str
+    quantity: int
+    size: Literal["s", "m", "l", "xl", "xxl"] | None
+    style: str | None
+
+
+# -- document: email
+class DocumentEmailMeta(BaseModel):
+    type: str
+    title: str
+    template: str
+    sender: str
+    path: str | None = None
+
+
+class DocumentEmail(BaseModel):
+    meta: DocumentEmailMeta
+    blocks: list[DocumentBlock]
+
+
+# -- NOW Payments invoice payload
+class NowPaymentsInvoice(BaseModel):
+    price_amount: float
+    price_currency: str
+    ipn_callback_url: str
+    order_id: str
+    success_url: str | None = None
+    cancel_url: str | None = None
+
+
+def create_product_id(
+    product_id: str,
+    size: Literal["s", "m", "l", "xl", "xxl"] | None,
+    style: str | None,
+):
+    """Create the product id for the selected option combination (size,
+    style) of the given product.
+    """
+    if size and style:
+        return f"{product_id}__{size}-{style}"
+    else:
+        return f"{product_id}"

+ 236 - 0
app/serializer.py

@@ -0,0 +1,236 @@
+from pathlib import Path
+
+from app.schema import DocumentProduct, CartOrderInfo, CartSessionItemOptions
+
+
+def typeset_flat_list(item_key: str, item_list: list[str | int]) -> str:
+    """Convert flat list to string in the following format:
+    => key: ["a", "b", 10, 44]
+    """
+    flat_list = []
+    for s in item_list:
+        flat_list.append(f'"{s}"')
+
+    item = f"  {item_key}: [{', '.join(flat_list)}]"
+
+    return item
+
+
+def convert_pydantic_product_to_text(doc: DocumentProduct) -> str:
+    """Convert a Pydantic model object (Product) into a text document
+    ready to be written to disk.
+    """
+    doc_dict = doc.model_dump()
+
+    doc_dict_meta = doc_dict["meta"]
+
+    # -- options
+    options = []
+    if doc.meta.options:
+        size = typeset_flat_list("size", doc_dict_meta["options"]["size"])
+        style = typeset_flat_list("style", doc_dict_meta["options"]["style"])
+
+        options.append("options:")
+        options.append(size)
+        options.append(style)
+
+    if len(options):
+        options = "\n".join(options)
+
+    # -- inventory
+    inventory = ["inventory:"]
+    for item in doc_dict_meta["inventory"]:
+        unit = []
+
+        if item["size"]:
+            unit.append(f"  - size: \"{item['size']}\"")
+        else:
+            unit.append("  - size:")
+
+        if item["style"]:
+            unit.append(f"    style: \"{item['style']}\"")
+        else:
+            unit.append("    style:")
+
+        unit.append(f"    amount: {item['amount']}")
+
+        unit = "\n".join(unit)
+        inventory.append(unit)
+
+    inventory = "\n".join(inventory)
+
+    # -- assemble txt_meta
+    txt_meta = (
+        "---",
+        f"type: \"{doc_dict_meta['type']}\"",
+        f"title: \"{doc_dict_meta['title']}\"",
+        f"template: \"{doc_dict_meta['template']}\"",
+        f"code: \"{doc_dict_meta['code']}\"",
+        f"product_id: \"{doc_dict_meta['product_id']}\"",
+        f"price: {doc_dict_meta['price']}",
+        f"weight: {doc_dict_meta['weight']}",
+        f"customs_tariff_code: \"{doc_dict_meta['customs_tariff_code']}\"",
+        f"origin_country_iso: \"{doc_dict_meta['origin_country_iso']}\"",
+        f"category: \"{doc_dict_meta['category']}\"",
+    )
+
+    if len(options):
+        txt_meta = txt_meta + (f"{options}",)
+
+    txt_meta = txt_meta + (f"{inventory}",)
+    # --
+
+    txt_blocks = []
+    for block in doc_dict["blocks"]:
+        if block["type"] == "text":
+            txt_blocks.append(block["value"])
+
+        elif block["type"] == "image":
+            caption = ""
+            if block["caption"]:
+                caption = f"caption: \"{block['caption']}\""
+
+            url_p = Path(block["url"])
+            url = f"{url_p.stem}{url_p.suffix}"
+
+            block_img = (
+                f"type: \"{block['type']}\"",
+                f'url: "{url}"',
+                f"{caption}",
+            )
+
+            block_img = "\n".join(block_img)
+            txt_blocks.append(block_img)
+
+    txt_meta = "\n".join(txt_meta)
+    txt_blocks = "\n---\n".join(txt_blocks)
+    txt_doc = "\n---\n".join((txt_meta, txt_blocks))
+    txt_doc = f"{txt_doc}\n"
+
+    return txt_doc
+
+
+def format_yaml_multiline(text: str, indent: bool = False) -> str:
+    """
+    Manually indent multiline values for YAML key.
+    """
+
+    lines = text.splitlines()
+    if indent:
+        lines = [f"{line.rjust(len(line) +4)}" for line in lines]
+        
+    lines = "\n".join(lines)
+
+    return lines
+
+
+def typeset_order_content(
+    items_table, currency: str, subtotal: int, shipping: int, total: int
+) -> str:
+    """Return a multiline string of the orders' content (list of product
+    items, price, etc.). Useful for when sending out an email.
+    """
+    content_order_txt = []
+
+    for i in items_table:
+        item_txt = f"- {i.quantity}x {i.title}, {currency} {i.price}\n"
+        content_order_txt.append(item_txt)
+
+    subtotal_txt = f"\nSubtotal {currency} {subtotal}\n"
+    shipping_txt = f"Shipping {currency} {shipping}\n"
+    total_txt = f"Total {currency} {total}"
+
+    content_order_txt.append(subtotal_txt)
+    content_order_txt.append(shipping_txt)
+    content_order_txt.append(total_txt)
+
+    return "".join(content_order_txt)
+
+
+def typeset_order_shipping_info(shipping_info):
+    """Return a multiline string of the shipping info, useful for when
+    sending out an email.
+    """
+    shipping_info_txt = []
+
+    if shipping_info.first_name:
+        shipping_info_txt.append(
+            f"{shipping_info.first_name} {shipping_info.last_name}\n"
+        )
+    else:
+        shipping_info_txt.append(f"{shipping_info.last_name}\n")
+
+    address_full = f"{shipping_info.address}, {shipping_info.address_no}"
+    shipping_info_txt.append(f"{address_full}\n")
+
+    if shipping_info.address_extra:
+        shipping_info_txt.append(f"{shipping_info.address_extra}\n")
+
+    shipping_info_txt.append(f"{shipping_info.postal_code} {shipping_info.city}\n")
+    shipping_info_txt.append(f"{shipping_info.country}")
+
+    if shipping_info.phone_number:
+        shipping_info_txt.append(f"{shipping_info.phone_number}")
+
+    if shipping_info.note:
+        note = format_yaml_multiline(shipping_info.note)
+        shipping_info_txt.append(f"\n\n{note}")
+
+    shipping_info_txt = "".join(shipping_info_txt)
+
+    return shipping_info_txt
+
+
+def format_order_items(items: list[CartOrderInfo | dict[str, str | int]]) -> str:
+    """
+    """
+
+    items_table = []
+    for i in items:
+        
+        if type(i) is dict:
+            i['options'] = CartSessionItemOptions(size=None, style=None)
+            i = CartOrderInfo(**i)
+        
+        item_title = i.title
+         
+        if i.options.size and i.options.style:
+            item_title = f"{item_title} ({i.options.style.capitalize()} / Size {i.options.size.upper()})"
+             
+        item = (
+            f"  - code: '{i.code}'\n"
+            f"    title: '{item_title}'\n"
+            f"    quantity: '{i.quantity}'\n"
+            f"    price: '{i.price}'\n"
+        )
+         
+        items_table.append(item)
+         
+    items_table = "".join(items_table)
+
+    return items_table
+
+
+def format_order_shipping_info(shipping_info) -> str:
+    """
+    """
+
+    note = ""
+    if shipping_info.note:
+        note = f"|\n{format_yaml_multiline(shipping_info.note, indent=True)}"
+
+    shipping_info_table = (
+        f"  first_name: '{shipping_info.first_name or ''}'\n"
+        f"  last_name: '{shipping_info.last_name}'\n"
+        f"  address: '{shipping_info.address}'\n"
+        f"  address_no: '{shipping_info.address_no}'\n"
+        f"  address_extra: '{shipping_info.address_extra or ''}'\n"
+        f"  postal_code: '{shipping_info.postal_code}'\n"
+        f"  city: '{shipping_info.city}'\n"
+        f"  country: '{shipping_info.country}'\n"
+        f"  note: '{note}'\n"
+        f"  email: '{shipping_info.email}'\n"
+        f"  phone_number: '{shipping_info.phone_number or ''}'\n"
+    )
+
+    return shipping_info_table

+ 130 - 0
app/shipping-cost.json

@@ -0,0 +1,130 @@
+{
+  "areas": {
+    "Switzerland": [
+      {
+	"price": 7.2,
+	"min": 1,
+	"max": 100
+      },
+      {
+	"price": 7.7,
+	"min": 101,
+	"max": 250
+      },
+      {
+	"price": 16.5,
+	"min": 251,
+	"max": 500
+      },
+      {
+	"price": 16.5,
+	"min": 501,
+	"max": 1000
+      },
+      {
+	"price": 16.5,
+	"min": 1001,
+	"max": 1500
+      },
+      {
+	"price": 16.5,
+	"min": 1501,
+	"max": 2000
+      },
+      {
+	"price": 19.5,
+	"min": 2001,
+	"max": 5000
+      },
+      {
+	"price": 19.5,
+	"min": 5001,
+	"max": null
+      }
+    ],
+    "Europe": [
+      {
+	"price": 10,
+	"min": 1,
+	"max": 100
+      },
+      {
+	"price": 15,
+	"min": 101,
+	"max": 250
+      },
+      {
+	"price": 20,
+	"min": 251,
+	"max": 500
+      },
+      {
+	"price": 26,
+	"min": 501,
+	"max": 1000
+      },
+      {
+	"price": 31,
+	"min": 1001,
+	"max": 1500
+      },
+      {
+	"price": 36,
+	"min": 1501,
+	"max": 2000
+      },
+      {
+	"price": 51,
+	"min": 2001,
+	"max": 5000
+      },
+      {
+	"price": 56,
+	"min": 5001,
+	"max": null
+      }
+    ],
+    "International": [
+      {
+	"price": 13,
+	"min": 1,
+	"max": 100
+      },
+      {
+	"price": 18,
+	"min": 101,
+	"max": 250
+      },
+      {
+	"price": 23,
+	"min": 251,
+	"max": 500
+      },
+      {
+	"price": 33,
+	"min": 501,
+	"max": 1000
+      },
+      {
+	"price": 38,
+	"min": 1001,
+	"max": 1500
+      },
+      {
+	"price": 43,
+	"min": 1501,
+	"max": 2000
+      },
+      {
+	"price": 56,
+	"min": 2001,
+	"max": 5000
+      },
+      {
+	"price": 76,
+	"min": 5001,
+	"max": null
+      }
+    ]    
+  }
+}

+ 440 - 0
app/template.py

@@ -0,0 +1,440 @@
+import os
+import random
+import hashlib
+from pathlib import Path
+from typing import Literal
+
+import pycountry
+from starlette.datastructures import URL
+
+from app.db import (
+    check_product_availability,
+    get_docs_by_category,
+    get_product_by_product_id,
+)
+from app.parser import read_file
+from app.schema import (
+    CartSession,
+    CheckoutItem,
+    CheckoutItemUpdate,
+    DocumentBlock,
+    DocumentCheckout,
+    DocumentProduct,
+    Settings,
+)
+from app.serializer import typeset_order_content, typeset_order_shipping_info
+from slugify import slugify
+
+
+def prepare_product(doc: DocumentProduct) -> dict[str, list[DocumentBlock]]:
+    """Re-shape DocumentProductBlocks to the layout needs of
+    ./templates/product.html
+    """
+    blocks = {
+        "texts": [],
+        "images": [],
+    }
+
+    for block in doc.blocks:
+        if block.type == "text":
+            blocks["texts"].append(block)
+
+        elif block.type == "image":
+            blocks["images"].append(block)
+
+    return blocks
+
+
+def prepare_related_products(
+    docs: list[DocumentProduct], product_id: str
+) -> list[DocumentProduct]:
+    """Given a list of DocumentProduct coming from ./<products>/, remove
+    the product index page and pick a random selection of 5 products
+    to display in the Related Products section.
+    """
+    random.seed()
+
+    def generate_random_index(length):
+        for i in range(0, length):
+            N = 1 + random.randrange(3)
+            for j in range(0, N):
+                random_idx = random.randrange(length - j)
+                return random_idx
+
+    products = []
+    for doc in docs:
+        if doc.meta.path not in products:
+            if (
+                Path(doc.meta.path).stem != product_id
+                and doc.meta.template == "product"
+            ):
+                doc.blocks = prepare_product(doc)
+
+                # check if there's at least 1 image before adding this
+                # item to the list of related products
+                if len(doc.blocks["images"]) > 0:
+                    products.append(doc)
+
+    random_selection = []
+    products_length = len(products)
+    for i in range(0, products_length):
+        if len(random_selection) == 5:
+            break
+
+        idx = generate_random_index(products_length)
+
+        if products[idx] not in random_selection:
+            random_selection.append(products[idx])
+
+    return random_selection
+
+
+def prepare_checkout(
+    cart: CartSession,
+    settings: Settings,
+    doc: DocumentCheckout,
+    products: list[DocumentProduct],
+    js_update: bool = False,
+) -> dict[str, list[str] | dict[str, str | int] | DocumentCheckout]:
+    """Return dictionary with necessary data to display the Checkout view:
+    - list of countries for final checkout form
+    - list of checkout items, including whether they're still
+      available (eg. meanwhile somebody else might have bought any of
+      them); we update the cart itself while doing this to minimize
+      the number of duplicate operations that otherwise would happen
+      in a different yet similar function
+    - checkout document
+    """
+    # -- prepare list of countries
+    countries = [country.name for country in pycountry.countries]
+
+    # -- prepare checkout items and update cart
+
+    show_checkout = []
+
+    # map over each item in the cart and create a new item object
+    checkout_items = []
+    for item in cart.items.values():
+        product = get_product_by_product_id(settings, item.product_id)
+        
+        checkout_item = {
+            "path": product.meta.path,
+            "title": product.meta.title,
+            "image": {"url": "", "width": 0},
+            "product_id": item.product_id,
+            "options": item.options,
+            "price": item.price,
+            "weight": product.meta.weight,
+            "quantity": item.quantity,
+            "total": item.price * item.quantity,
+            "availability": False,
+            "is_selectable": False,
+        }
+
+        item_image = [block for block in product.blocks if block.type == "image"]
+
+        if len(item_image) > 0:
+            item_image = item_image[0]
+            checkout_item["image"]["url"] = item_image.url
+            checkout_item["image"]["width"] = item_image.info.width
+            checkout_item["image"]["height"] = item_image.info.height
+
+
+        triplet = check_product_availability(
+            product,
+            Path(checkout_item["path"]).stem,
+            checkout_item["quantity"],
+            checkout_item["options"].size,
+            checkout_item["options"].style,
+            settings,
+        )
+
+        is_product_available, is_product_selectable, inventory_amount = triplet
+
+        show_checkout.append(is_product_available)
+
+        checkout_item["availability"] = is_product_available
+        checkout_item["is_selectable"] = is_product_selectable
+
+        if js_update:
+            checkout_item = CheckoutItemUpdate(**checkout_item)
+
+        else:
+            checkout_item = CheckoutItem(**checkout_item)
+
+        checkout_items.append(checkout_item)
+
+    return {
+        "countries": countries,
+        "checkout_items": checkout_items,
+        "show_checkout": any(show_checkout),
+        "doc": doc,
+    }
+
+
+def prepare_sets_for_menu(settings: Settings) -> list[dict[str, str]]:
+    """Get Lookbook pages and return only dict with title and path for
+    each doc.
+    """
+    lookbooks = get_docs_by_category("lookbook", settings)
+    sets = [
+        {"title": lookbook.meta.title, "path": lookbook.meta.path}
+        for lookbook in lookbooks
+    ]
+
+    return sets
+
+
+def prepare_email_order(order_id: str, settings: dict[str, list[str]]) -> dict[str]:
+    """Prepare data for email order."""
+    filename = "orders"
+    orders = read_file(
+        settings["git_repo"],
+        filename,
+        settings["document_match"],
+        settings["block_types"],
+        "",
+    )
+
+    order = [order for order in orders.blocks if order.order_id == order_id]
+
+    if len(order) > 0:
+        order = order[0]
+
+        content = typeset_order_content(
+            order.items, order.currency, order.subtotal, order.shipping, order.total
+        )
+
+        shipping_info = typeset_order_shipping_info(order.shipping_info)
+
+        return {
+            "order_id": order.order_id,
+            "content": content,
+            "payment_provider": order.payment_provider,
+            "payment_reference": order.payment_reference,
+            "shipping_info": shipping_info,
+            "email": order.shipping_info.email,
+        }
+
+
+def make_srcset_url(
+    selected_size_width: int,
+    img_width: int,
+    fileparent,
+    parent,
+    p,
+    BASE_URL: str,
+    size_attr: bool,
+):
+    """Return a srcset URL in the format."""
+    # check if img width is bigger than given srcset value
+    # (eg. avoid to produce a srcset rule for an actual image file
+    # that we did not produce as a thumbnail)
+
+    if img_width > selected_size_width:
+        # prepare a string for each srcset rule
+        filename_new = f"{fileparent}/{Path(parent).stem}__{p.stem}_{selected_size_width}{p.suffix}"
+
+        if size_attr:
+            filename_new = f"{filename_new} {selected_size_width}w"
+
+        new_url = f"{BASE_URL}{filename_new}"
+
+        return new_url
+
+
+def to_srcset(
+    url,
+    template: Literal["main", "product-grid", "product", "page", "checkout"],
+    img_width: int | None,
+    parent: str,
+    size: int | None = None,
+    size_attr: bool = True,
+) -> str:
+    """Generate correct srcset-style URL to send to the backend
+    and get back the desired resized image.
+
+    Eg:
+    => path/to/image.jpg
+       path/to/image_<size>w.jpg
+
+    URL can also be a str in the format => <filename>.<ext>
+    """
+    SIZES = {
+        "main": [
+            (320,),
+            (640,),
+            (760,),
+            (1024,),
+            (1366,),
+            (1600,),
+            (1920,),
+            (2400,),
+            (2880,),
+            (3840,),
+        ],
+        "product-grid": [
+            (320,),
+            (640,),
+            (380,),
+            (760,),
+            (256,),
+            (512,),
+            (340,),
+            (683,),
+            (400,),
+            (800,),
+            (480,),
+            (960,),
+            (460,),
+            (920,),
+            (560,),
+            (1120,),
+        ],
+        "product": [
+            (320,),
+            (640,),
+            (760,),
+            (1024,),
+            (1200,),
+            (1366,),
+            (1920,),
+            (2400,),
+        ],
+        "page": [
+            (320,),
+            (640,),
+            (760,),
+            (1024,),
+            (1366,),
+            (1600,),
+            (1920,),
+            (2400,),
+        ],
+        "checkout": [(120, 120), (240, 240), (220, 220), (440, 440)],
+    }
+
+    if type(url) is URL:
+        BASE_URL = f"{url.scheme}://{url.netloc}"
+
+        # eg. /media/AJ5.jpg => /media/AJ5_<size>w.jpg
+        tokens = url.path.split("/")
+        fileparent = "/".join(tokens[:-1])
+        filename = tokens[-1]
+        p = Path(filename)
+
+        template_sizes = SIZES[template]
+
+        if size:
+            selected_size_width = template_sizes[size][0]
+
+            if img_width:
+                return make_srcset_url(
+                    selected_size_width,
+                    img_width,
+                    fileparent,
+                    parent,
+                    p,
+                    BASE_URL,
+                    size_attr,
+                )
+            
+            else:
+                return ""
+
+        # --
+        srcset_list = []
+        for selected_size in template_sizes:
+            selected_size_width = selected_size[0]
+
+            if img_width:
+                new_url = make_srcset_url(
+                    selected_size_width,
+                    img_width,
+                    fileparent,
+                    parent,
+                    p,
+                    BASE_URL,
+                    size_attr,
+                )
+
+                srcset_list.append(new_url)
+
+        # combine all srcset rules into one string
+        srcset_list = [srcset for srcset in srcset_list if srcset]
+        srcset_matrix = ",\n".join(srcset_list)
+
+        return srcset_matrix
+
+    elif type(url) is str:
+        # sometimes if writing HTML wrongly for the jinja2 template
+        # (?) starlette return a url string and not a url object. in
+        # this case just return the url string as-is.
+        return url
+
+
+def assets_hashing(resource: str) -> str:
+    """
+    Append the hash of <resource>'s modified time to the given <resource>,
+    to make the web browser fetch the latest version of <resource>.
+    Eg. soft cache-invalidation.
+
+    => /<resource>-<file-modified-hash>.<ext>
+    => /styles-<hash>.css
+    """
+
+    fp = Path(f"{Path(__file__).parent.parent}/{resource.path}")
+    filepath = Path(resource.path)
+
+    if fp.exists:
+        mtime = fp.stat().st_mtime
+
+        # hash mtime with sha256
+        hash_mtime = hashlib.sha256(b"{mtime}")
+        hex_digest = hash_mtime.hexdigest()
+        
+        return f"{filepath.parent}/{filepath.stem}-{hex_digest[:7]}{filepath.suffix}"
+    
+    else:
+        return f"{resource}"
+    
+
+def make_product_selected(
+    size: str | None, style: str | None, quantity: int
+) -> dict[str, str | int]:
+    """Helper function to construct a dictionary with only not-None
+    values. We use this as a JSON response, to let javascript update
+    the current URL with a querystring with the selected product
+    options. In case an option is None, we simply don't want to add it
+    as query param.
+    """
+    product_selected = {"quantity": quantity}
+
+    if size:
+        product_selected["size"] = size
+    if style:
+        product_selected["style"] = style
+
+    return product_selected
+
+
+def make_product_info_code(item):
+    """
+    Output a product info string in the format:
+
+    <item.path>__<item.options.size>--<item.options.style>--<item.quantity>
+    """
+
+    p = item.path
+    o = ""
+    q = item.quantity
+
+    if item.options.size and item.options.style:
+        o = f"{item.options.size}-{item.options.style}"
+
+
+    return f"{p}__{o}--{q}"
+
+
+def make_slugify(text: str) -> str:
+    return slugify(text)

+ 55 - 0
app/templates/base.html

@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<html dir="ltr" lang="en-US">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta name="description" content="">
+    <meta name="color-scheme" content="dark">
+
+    <link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', path='/apple-touch-icon.png') }}">
+    <link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', path='/favicon-32x32.png') }}">
+    <link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', path='/favicon-16x16.png') }}">
+    <link rel="manifest" href="{{ url_for('static', path='/site.webmanifest') }}">
+
+    <title>Dark.Fi Hardware</title>
+
+    {% if CACHE == 'enable' %}
+    <link rel="stylesheet" type="text/css" href="{{ url_for('static', path='/dist/styles-XHZV2ERZ.css') }}">
+
+    <link rel="preload" href="{{ url_for('static', path='/dist/fonts/IBMPlexMono-Light.woff2') }}" as="font" type="font/woff2" crossorigin>
+    <link rel="preload" href="{{ url_for('static', path='/dist/fonts/IBMPlexMono-LightItalic.woff2') }}" as="font" type="font/woff2" crossorigin>
+    <link rel="preload" href="{{ url_for('static', path='/dist/fonts/Inter-Bold.woff2') }}" as="font" type="font/woff2" crossorigin>
+    <link rel="preload" href="{{ url_for('static', path='/dist/fonts/Inter-BoldItalic.woff2') }}" as="font" type="font/woff2" crossorigin>
+    <link rel="preload" href="{{ url_for('static', path='/dist/fonts/weissrundgotisch.regular.woff2') }}" as="font" type="font/woff2" crossorigin>
+
+    {% if request.url.path.startswith('/products/') %}
+    <script type="module" src="{{ url_for('static', path='/dist/product-R6DMP5AX.js') }}"></script>
+    {% endif %}
+    {% if request.url.path == '/checkout' %}
+    <script src="https://js.stripe.com/v3/"></script>
+    <script type="module" src="{{ url_for('static', path='/dist/checkout-7PTSF3BQ.js') }}"></script>
+    {% endif %}
+
+    {% else %}
+    <link rel="stylesheet" type="text/css" href="{{ url_for('static', path='/css/styles.css') }}">
+
+    <link rel="preload" href="{{ url_for('static', path='/fonts/IBMPlexMono-Light.woff2') }}" as="font" type="font/woff2" crossorigin>
+    <link rel="preload" href="{{ url_for('static', path='/fonts/IBMPlexMono-LightItalic.woff2') }}" as="font" type="font/woff2" crossorigin>
+    <link rel="preload" href="{{ url_for('static', path='/fonts/Inter-Bold.woff2') }}" as="font" type="font/woff2" crossorigin>
+    <link rel="preload" href="{{ url_for('static', path='/fonts/Inter-BoldItalic.woff2') }}" as="font" type="font/woff2" crossorigin>
+    <link rel="preload" href="{{ url_for('static', path='/fonts/weissrundgotisch.regular.woff2') }}" as="font" type="font/woff2" crossorigin>
+
+    {% if request.url.path.startswith('/products/') %}
+    <script type="module" src="{{ url_for('static', path='/js/product.js') }}"></script>
+    {% endif %}
+    {% if request.url.path == '/checkout' %}
+    <script src="https://js.stripe.com/v3/"></script>
+    <script type="module" src="{{ url_for('static', path='/js/checkout.js') }}"></script>
+    {% endif %}
+    {% endif %}
+  </head>
+  <body class="x xdc nav-height bgc-black">
+    {% include "partials/nav.html" %}
+    {% block content %}{% endblock %}
+  </body>
+</html>

+ 15 - 0
app/templates/checkout-error.html

@@ -0,0 +1,15 @@
+{% extends "base.html" %}
+
+{% block content %}
+<main class="x xdc md-xdr md-xw">
+    <header class="w100 copy-h tac">
+      <h1>Order checkout</h1>
+    </header>
+    <section class="w100 bdb-dark-red pt4-25 pr0-85 pb4-25 pl0-85 copy ff-mono">
+      <div class="mw-680px mla mra">
+	<p class="pb1">An error occured during the checkout.</p>
+	<p class="copy">↪ <a href="/checkout" aria-label="Go to Checkout">Back to cart</a>.</p>
+      </div>
+    </section>
+</main>
+{% endblock %}

+ 17 - 0
app/templates/checkout-success.html

@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+
+{% block content %}
+<main class="x xdc md-xdr md-xw">
+  <header class="w100 copy-h tac">
+    <h1>Order checkout</h1>
+  </header>
+  <section class="w100 bdb-dark-red pt4-25 pr0-85 pb4-25 pl0-85 copy ff-mono">
+    <div class="mw-680px mla mra">
+      <p class="pb1">Thanks for your order.</p>
+      <p class="pb1">An email confirmation has been sent to <em>{{ data.customer_email }}</em>. It might take a few minutes to reach your inbox.</p>
+      
+      <p><span><strong>Order number</strong></span> <span>{{ data.order_id }}</span></p>
+    </div>
+  </section>
+</main>
+{% endblock %}

+ 74 - 0
app/templates/checkout.html

@@ -0,0 +1,74 @@
+{% extends "base.html" %}
+
+{% block content %}
+<main class="x xdc md-xdr md-xw">
+  <header class="w100 copy-h tac">
+    <h1>Cart</h1>
+  </header>
+  {% if not data.checkout_items %}
+  <section class="w100 pt4-25 pr0-85 pb4-25 pl0-85 copy">
+    <div class="mw-680px mla mra">
+      <p>Your cart is empty.</p>
+    </div>
+  </section>
+  {% else %}
+  <section class="checkout-table-header w100 pt4-25">
+    {% for item in data.checkout_items %}
+    {% include "partials/checkout-item.html" %}
+    {% endfor %}
+  </section>
+  {% if data.show_checkout %}
+  <form class="checkout-shipping-info w100 pt4-25" method="POST">
+    <input type="hidden" id="order_id" name="order_id" value="{{ cart.meta.order_id }}">
+    {% for item in data.checkout_items %}
+    <input type="hidden" id="weight" name="weight" value="{{ item.weight }}">
+    {% endfor %}
+    <section class="w100 bdb-dark-red pr0-85 pb4-25 pl0-85">
+      {% include "partials/form-subtotal.html" %}
+    </section>
+    <section class="w100 pt4-25 pr0-85 pb4-25 pl0-85 bdb-dark-red">
+      <div class="mw-680px mla mra">
+	<h4 class="ff-sans fs-huge ttu pb4-75">Shipping Information</h4>
+	{% include "partials/form-shipping-info.html" %}
+      </div>
+    </section>
+    <section class="w100 pt4-25 pr0-85 pb5-5 pl0-85 bdb-dark-red">
+      {% include "partials/form-total.html" %}
+    </section>
+    <section class="x xdr xw xjsb xaic w100 pt4-25 pr0-85 pb4-25 pl0-85 bdb-gray">
+      <div class="mw-680px mla mra">
+	<div class="w100 x xdc tn-xdr xjsb sm-xaic pt1 pb1">
+	  <h4 class="ff-sans fs-large sm-fs-huge ttu pb2">Checkout</h4>
+	  {% include "partials/form-payment-options.html" %}
+	</div>
+	<div class="copy-checkout-disclaimer w100 pt3-5">
+	  {% for block in data.doc.blocks %}
+	  {% if block.type == 'text' %}
+	  {{ block.value | md | safe }}
+	  {% endif %}
+	  {% endfor %}
+	</div>
+      </div>
+    </section>
+    <input type="hidden" name="csrftoken" value="{{ request.scope.csrftoken() }}">
+  </form>
+  {% else %}
+  <form class="w100 pt4-25 pr0-85 pb4-25 pl0-85 bdb-dark-red" action="/cart/clear" method="POST">
+    <div class="mw-680px mla mra">
+      <h4 class="ff-sans fs-large sm-fs-huge pb2">Do you want to clear you cart?</h4>
+      <button type="submit" class="fs-small btn-checkout-remove bd-deep-brown fgc-terre bgc-gaow">Clear Cart</button>
+    <input type="hidden" name="csrftoken" value="{{ request.scope.csrftoken() }}">
+    </div>
+  </form>
+  {% endif %}
+  <div class="checkout-empty-message dn w100 bdb-dark-red pt4-25 pr0-85 pb4-25 pl0-85 copy">
+    <div class="mw-680px mla mra">
+      <p>Your cart is empty.</p>
+    </div>
+  </div>
+  {% endif %}
+</main>
+{% with hide_menu=True, custom_border=not data.checkout_items %}
+{% include "partials/footer.html" %}
+{% endwith %}
+{% endblock %}

+ 26 - 0
app/templates/error.html

@@ -0,0 +1,26 @@
+{% extends "base.html" %}
+
+{% block content %}
+<main class="x xdc lg-xdr lg-xw">
+  <header class="w100 copy-h tac">
+    <h1>Error</h1>
+  </header>
+  <section class="w100 lg-w50 hg-w1200 mw-680px mla mra">
+    <div class="pt1 pb1-7 copy">
+      {{ data.error | md | safe }}
+      <p class="pt1">↪ <a href="/checkout" aria-label="Go to Checkout">Back to cart</a>.</p>
+    </div>
+
+    {% if data.status_code == 422 %}
+    <form action="/cart/clear" method="POST">
+      <p class="copy">
+	Clear your previous order before proceeding with a new one:
+	<button type="submit fgc-cyan">Clear Order</button>
+      </p>
+      <input type="hidden" name="csrftoken" value="{{ request.scope.csrftoken() }}">
+    </form>
+    {% endif %}
+  </section>
+</main>
+
+{% endblock %}

+ 42 - 0
app/templates/index.html

@@ -0,0 +1,42 @@
+{% extends "base.html" %}
+
+{% block content %}
+<main class="x xdc">
+  <div class="x xdc bg-xdr">
+    <section class="bdb-gray bg-w75 bg-xo2">
+      {% for block in doc.blocks %}
+      {% if block.type == 'image' %}
+      {% include "partials/block-image-cover.html" %}
+      {% endif %}
+      {% endfor %}
+    </section>
+    <section class="x xw xdr bdb-gray pt1-7 pr0-85 pb1-7 pl0-85 bg-w25 bg-xo1 bg-bdr-gray">
+      <div class="x xdr bg-xdc w50 md-w25 bg-w100 g-4-16">
+	<div class="x xdc">
+	  <h3 class="fgc-gray ttu pb1-7">categories</h3>
+	  {% for cat in categories.keys() %}
+	  <a href="#{{ cat | lower }}" class="fgc-cyan ttu{% if not loop.last %} pb1-7{% endif %}">{{ cat }}</a>
+	  {% endfor %}
+	</div>
+	<div class="x xdc">
+	  <h3 class="fgc-gray ttu pb1-7">set</h3>
+	  {% for set in sets %}
+	  <a href="/{{ set.path }}" class="fgc-cyan ttu{% if not loop.last %} pb1-7{% endif %}">{{ set.title }}</a>
+	  {% endfor %}
+	</div>
+      </div>
+    </section>
+  </div>
+  {% for cat, products in categories.items() %}
+  <section class="copy-h">
+    <h2 id="{{ cat | lower }}" class="bdb-gray">{{ cat }}</h2>
+    <div class="dg grid-2 related-products">
+      {% for product in products %}
+      {% set block = product.blocks[0] %}
+      {% include "partials/grid-product.html" %}
+      {% endfor %}
+  </section>
+  {% endfor %}
+</main>
+{% include "partials/footer.html" %}
+{% endblock %}

+ 28 - 0
app/templates/page.html

@@ -0,0 +1,28 @@
+{% extends "base.html" %}
+
+{% block content %}
+<main class="x xdc lg-xdr lg-xw bdb-gray">
+  <section class="w100 lg-w50 hg-w1200">
+    {% for block in doc.blocks %}
+    {% if block.type == 'text' %}
+    <section class="pb1-7 copy-h copy-page copy {% if not loop.first %} bdt-gray{% endif %}">
+      {{ block.value | md | safe }}
+    </section>
+    {% elif block.type == 'image' %}
+    <figure class="img-page {% if not loop.first %} bdt-gray{% endif %}">
+      <img src="{{ url_for('static', path='/images/cache/' + block.url) | to_srcset('page', block.info.width, doc.meta.path, size=2, size_attr=False) }}"
+	   srcset="{{ url_for('static', path='/images/cache/' + block.url) | to_srcset('page', block.info.width, doc.meta.path) }}"
+	   sizes="(max-width: 759px) 100vw, 50vw"	 	   
+	   alt="{{ block.url }}"
+	   width="{{ block.info.width }}"
+	   height="{{ block.info.height }}"
+      >
+    </figure>
+    {% endif %}
+    {% endfor %}
+  </section>
+  <section class="w100 lg-w50 lg-x lg-xdc lg-bdl-pink hg-x1">
+  </section>
+</main>
+{% include "partials/footer.html" %}
+{% endblock %}

+ 12 - 0
app/templates/partials/block-image-cover.html

@@ -0,0 +1,12 @@
+<figure class="psr">
+  <img src="{{ url_for('static', path='/images/cache/' + block.url) | to_srcset('main', block.info.width, doc.meta.path, size=2, size_attr=False) }}"
+       srcset="{{ url_for('static', path='/images/cache/' + block.url) | to_srcset('main', block.info.width, doc.meta.path) }}"
+       sizes="(max-width: 1919px) 100vw, 75vw"       
+       alt="{{ block.url }}"
+       width="{{ block.info.width }}"
+       height="{{ block.info.height }}"
+  >
+  {% if block.caption %}
+  <figcaption class="psa t50 l50 ttcc fs-large fgc-cyan ff-sans bgc-blackest">{{ block.caption | md | safe }}</figcaption>
+  {% endif %}
+</figure>

+ 12 - 0
app/templates/partials/block-image.html

@@ -0,0 +1,12 @@
+<figure class="psr {% if not block_last %}bdb-gray {% endif %}{% if img_hide %} dn md-db{% endif %}">
+  <img src="{{ url_for('static', path='/images/cache/' + block.url) | to_srcset('product', block.info.width, doc.meta.path, size=2, size_attr=False) }}"
+       srcset="{{ url_for('static', path='/images/cache/' + block.url) | to_srcset('product', block.info.width, doc.meta.path) }}"
+       sizes="(max-width: 759px) 100vw, 50vw"
+       alt="{{ block.url }}"
+       width="{{ block.info.width }}"
+       height="{{ block.info.height }}"
+  >
+  {% if block.caption %}
+  <figcaption class="psa b0 l0 fs-big pt0-5 pr0-5 pb0-5 pl0-5 bgc-blackest">{{ block.caption }}</figcaption>
+  {% endif %}
+</figure>

+ 1 - 0
app/templates/partials/block-text.html

@@ -0,0 +1 @@
+<div>{{ block.value | md | safe }}</div>

+ 3 - 0
app/templates/partials/cart.html

@@ -0,0 +1,3 @@
+{% if cart %}
+<a href="/checkout" aria-label="Go to Checkout" data-status="free" class="cart-icon mla fs-small sm-fs-medium">cart (<span class="cart-icon--amount">{{ cart['meta']['item_amount'] }}</span>)</a>
+{% endif %}

+ 30 - 0
app/templates/partials/checkout-item-form.html

@@ -0,0 +1,30 @@
+<form class="checkout-item" action="/checkout" method="POST">
+  {% if operation == 'delete' %}
+  <button type="submit" class="fs-small btn-checkout-remove bd-deep-brown fgc-terre bgc-gaow">remove</button>
+  {% elif operation == 'remove' %}
+  <button type="submit" class="db psr icon-p">{% include "partials/icon-minus.html" %}</button>
+  {% elif operation == 'add' %}
+  <button id="button-plus" type="submit" {% if not item.is_selectable %}disabled {% endif %}class="{% if not item.is_selectable %}cura {% endif %}db psr icon-p">{% include "partials/icon-plus.html" %}</button>
+  {% endif %}
+  <input type="hidden" id="page" name="page" value="checkout">
+  <input type="hidden" id="operation" name="operation" value="{{ operation }}">
+  <input type="hidden" id="product_id" name="product_id" value="{{ item.product_id }}">
+  <input type="hidden" id="product_path" name="product_path" value="{{ item.path }}">
+  <input type="hidden" id="price" name="price" value="{{ item.price }}">
+  {% if item.options.style %}
+  <input type="hidden" id="style" name="style" value="{{ item.options.style }}">
+  {% endif %}
+  {% if item.options.style %}
+  <input type="hidden" id="size" name="size" value="{{ item.options.size }}">
+  {% endif %}
+  {% if operation == 'delete' %}
+  <input type="hidden" id="quantity_delete" name="quantity" value="{{ item.quantity }}">
+  {% elif operation in 'remove' %}
+  <input type="hidden" id="quantity_remove" name="quantity" value="1">
+  {% elif operation in 'add' %}
+  <input type="hidden" id="quantity_add" name="quantity" value="1">
+  {% elif operation == 'manual' %}
+  <input type="text" aria-label="Quantity" id="quantity_manual" name="quantity" value="{{ item.quantity }}" class="fs-bigger sm-fs-huge tac w5r mr1 ml1" autocomplete="off">
+  {% endif %}
+  <input type="hidden" name="csrftoken" value="{{ request.scope.csrftoken() }}">
+</form>

+ 52 - 0
app/templates/partials/checkout-item.html

@@ -0,0 +1,52 @@
+<div id="{{ item.product_id }}" class="cart-table-row mw-680px mla mra x xdc pt1-7 pr0-85 pl0-85 sm-pl0 sm-pr0">
+  <div class="x xdr pb7">
+    <figure class="w50">
+      <img src="{{ url_for('static', path='/images/cache/' + item.image.url) | to_srcset('checkout', item.image.width, item.path, size=2, size_attr=False) }}"
+	   srcset="{{ url_for('static', path='/images/cache/' + item.image.url) | to_srcset('checkout', item.image.width, item.path) }}"
+	   sizes="(max-width: 540px) 120px, 220px"	   
+	   alt="{{ item.image.url }}"
+	   width="{{ item.image.width }}"
+	   height="{{ item.image.height }}"
+	   class="checkout-image bd-yet-gray">
+    </figure>
+    <div class="w50">
+      <h3 class="ff-sans fs-larger md-fs-wide pb2">
+	{{ item.title }}
+	{% if item.options.size and item.options.style %}
+	— {{ item.options.style | capitalize }} / Size {{ item.options.size | upper }}
+	{% endif %}
+      </h3>
+      {% set operation = 'delete' %}
+      {% include "partials/checkout-item-form.html" %}
+      {% set operation = False %}
+      {% if not item.availability %}
+      <div class="pt1-7">This product is soldout.</div>
+      {% endif %}
+    </div>
+  </div>
+  {% if item.availability %}
+  <div class="x xdr xjsb pb1-7 bdb-dark-green">
+    <div>
+      <h4 class="ff-sans ttu pb1 sm-fs-big">Price</h4>
+      <div class="fs-bigger sm-fs-huge">{{ item.price }} {{ cart.meta.currency }}</div>
+    </div>
+    <div class="tac">
+      <h4 class="ff-sans ttu pb1 sm-fs-big">Quantity</h4>
+      <div class="x xdr xaic xjcc">
+	{% set operation = 'remove' %}
+	{% include "partials/checkout-item-form.html" %}
+	{% set operation = 'manual' %}
+	{% include "partials/checkout-item-form.html" %}
+	{% set operation = 'add' %}
+	{% include "partials/checkout-item-form.html" %}
+	{% set operation = False %}
+      </div>
+    </div>
+    <div class="item-total tar">
+      <h4 class="ff-sans ttu pb1 sm-fs-big">Total</h4>
+      <div class="fs-bigger sm-fs-huge"><span class="item-total--text">{{ item.total }}</span> {{ cart.meta.currency }}</div>
+      <input type="hidden" id="price" name="price" value="{{ item.total }}">
+    </div>
+  </div>
+  {% endif %}
+</div>

+ 13 - 0
app/templates/partials/footer.html

@@ -0,0 +1,13 @@
+<footer class="x xdc bg-xdr bg-xw{% if custom_border %} bdt-dark-red mta{% endif %}">
+  {% if not hide_menu %}
+  <nav class="w100 bg-w50 x xdc bg-xdr bg-xw bg-xo2 bg-xjsb bdb-gray bg-bdl-gray pt1-7 pr0-75 pb1-7 pl0-75 bg-pt1-25 bg-pr1-7 bg-pb1-25 bg-pl1-7 lg-pt4-2 lg-pr8-3 lg-pb4-2 lg-pl8-3">
+    {% include "partials/menu.html" %}
+  </nav>
+  <div class="w100 bg-w50 bg-xo1 bdb-gray pt1-7 pr0-75 pb1-7 pl0-75 bg-pt1-25 bg-pr1-7 bg-pb1-25 bg-pl1-7 lg-pt4-2 lg-pr8-3 lg-pb4-2 lg-pl4-2">
+    <a href="https://dark.fi" class="fs-small bg-fs-big">back to dark.fi</a>
+  </div>
+  {% endif %}
+  <div class="w100 bg-xo3 bgc-gr-black h15vh">
+    <div class="sunset" style="background-image: url('{{ url_for('static', path='images/sunset-red.png')}}')"></div>
+  </div>
+</footer>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 11 - 0
app/templates/partials/form-payment-options.html


+ 49 - 0
app/templates/partials/form-shipping-info.html

@@ -0,0 +1,49 @@
+<label class="vh" for="email">Email address</label>
+<input type="text" aria-label="Email address" id="email" name="email" value="" placeholder="Email address" required="required" class="w100 form-input mb4-25 sm-fs-big">
+
+<label class="vh" for="first_name">First name (optional)</label>
+<input type="text" aria-label="First name (optional)" id="first_name" name="first_name" value="" placeholder="First name (optional)" class="w100 form-input mb1-25 sm-fs-big">
+
+<label class="vh" for="last_name">Last name</label>
+<input type="text" aria-label="Last name" id="last_name" name="last_name" value="" placeholder="Last name" required="required" class="w100  form-input mb1-25 sm-fs-big">
+
+<div class="x xdc md-xdr">
+  <label class="vh" for="address">Address</label>
+  <input type="text" aria-label="Address" id="address" name="address" value="" placeholder="Address" required="required" class="w100 md-w70 form-input mb1-25 sm-fs-big">
+  <label class="vh" for="address">Address No</label>
+  <input type="text" aria-label="Address Number" id="address_no" name="address_no" value="" placeholder="Address No." required="required" class="w100 md-w30 form-input md-ml1-25 mb1-25 sm-fs-big">
+</div>
+
+<label class="vh" for="address_extra">Apartment, suite, etc. (optional)</label>
+<input type="text" aria-label="Apartment, suite, etc. (optional)" id="address_extra" name="address_extra" value="" placeholder="Apartment, suite, etc. (optional)" class="w100 form-input mb1-25 sm-fs-big">
+
+<label class="vh" for="postal_code">Postal code</label>
+<input type="text" aria-label="Postal code" id="postal_code" name="postal_code" value="" placeholder="Postal code" required="required" class="w100 form-input mb1-25 sm-fs-big">
+
+<label class="vh" for="city">City</label>
+<input type="text" aria-label="City" id="city" name="city" value="" placeholder="City" required="required" class="w100 form-input mb1-25 sm-fs-big">
+
+<label class="vh" for="country">Country</label>
+<select aria-label="Country" id="country" name="country" value="" required="required" class="w100 form-input mb1-25 sm-fs-big">
+  <option value="">Country</option>
+  {% for country in data.countries %}
+  <option value="{{ country }}">{{ country }}</option>
+  {% endfor %}
+</select>
+
+<label class="vh" for="city">Phone Number</label>
+<input type="tel" aria-label="Phone Number" id="phone_number" name="phone_number" value="" placeholder="Phone Number" class="w100 form-input mb1-25 sm-fs-big">
+
+
+<section class="x xdr xjsb xaib pt4-25 pb5-5">
+  <h3 class="ff-sans fs-large sm-fs-huge">Shipping costs</h3>
+  <noscript>
+    <button type="submit" formaction="/checkout/shipping" class="bd-white fgc-white fs-biggish sm-fs-large pt0-25 pr0-75 pb0-25 pl0-75">Calculate Shipping</button>
+  </noscript>
+  <div class="subtotal--shipping fs-biggish sm-fs-large"><span class="amount">{% if cart.meta.shipping %}{{ cart.meta.shipping }}{% else %}-{% endif %}</span> {{ cart.meta.currency }}</div>
+</section>
+
+<section class="md-grid-c1-4 dg">
+  <label class="vh" for="note">Additional note (optional)</label>
+  <textarea aria-label="Additional note (optional)" id="note" name="note" rows="5" placeholder="Additional note (optional)" class="w100 form-input sm-fs-big"></textarea>
+</section>  

+ 6 - 0
app/templates/partials/form-subtotal.html

@@ -0,0 +1,6 @@
+<div class="x xdr xw xaib mw-680px mla mra">
+  <h4 class="w70 sm-w100 ff-sans fs-large sm-fs-larger sm-tar ttu pb3 xo1">Subtotal</h4>
+  <div class="subtotal--amount fs-bigger sm-fs-huge w30 xo2 sm-xo3 tar"><span class="amount">{{ cart.meta.subtotal }}</span> {{ cart.meta.currency }}</div>
+  <p class="w100 sm-w70 sm-fs-biggish xo3 sm-xo2">Shipping costs calculated below</p>
+</div>
+

+ 8 - 0
app/templates/partials/form-total.html

@@ -0,0 +1,8 @@
+<div class="x xdr xjsb mw-680px mla mra">
+  <h4 class="ff-sans fs-large sm-fs-huge ttu">Total</h4>
+  <div>
+    <div class="fgc-anon-gray tar fs-biggish sm-fs-large subtotal--amount"><span class="amount">{{ cart.meta.subtotal }}</span> {{ cart.meta.currency }}</div>
+    <div class="fgc-anon-gray tar fs-biggish sm-fs-large subtotal--shipping">(shipping) + <span class="amount">{{ cart.meta.shipping }}</span> {{ cart.meta.currency }}</div>
+  </div>
+</div>
+<div class="mw-680px mla mra tar fs-biggish sm-fs-large subtotal--total bdt-gray mt1 pt1 pb1"><span class="amount">{{ cart.meta.total }}</span> {{ cart.meta.currency }}</div>

+ 13 - 0
app/templates/partials/grid-product.html

@@ -0,0 +1,13 @@
+<a href="/{{ product.meta.path }}" class="related-products-child x" id="{{ product.meta.title | slugify }}">
+  <figure class="psr x x1">
+    <img src="{{ url_for('static', path='/images/cache/' + block.url) | to_srcset('product-grid', block.info.width, product.meta.path, size=4, size_attr=False) }}"
+	 srcset="{{ url_for('static', path='/images/cache/' + block.url) | to_srcset('product-grid', block.info.width, product.meta.path) }}"
+	 sizes="(max-width: 759px) 50vw, (max-width: 2879px) 25vw, 20vw"	 
+	 alt="{{ product.meta.title }}"
+	 width="{{ block.info.width }}"
+	 height="{{ block.info.height }}"
+	 class="x1"
+    >
+    <figcaption class="psa b0 l0 bgc-blackest pt0-25 pr0-25 pb0-25 pl0-25 lg-pt0-42 lg-pr0-42 lg-pb0-42 lg-pl0-42">{{ product.meta.title }} {{ product.meta.price }}{{ cart.meta.currency }}</figcaption>
+  </figure>	
+</a>

+ 3 - 0
app/templates/partials/icon-minus.html

@@ -0,0 +1,3 @@
+<svg width="15" height="2" viewBox="0 0 15 2" fill="none" xmlns="http://www.w3.org/2000/svg" class="psa t50 l50 ttcc icon-p">
+  <line x1="14.3605" y1="1.2035" x2="0.639544" y2="1.2035" stroke="currentColor" stroke-width="0.686047"/>
+</svg>

+ 4 - 0
app/templates/partials/icon-plus.html

@@ -0,0 +1,4 @@
+<svg viewBox="0 0 31 31" fill="none" xmlns="http://www.w3.org/2000/svg" class="psa t50 l50 ttcc icon-p {% if icon_big %}md-icon-p{% endif %}">
+  <line x1="16" y1="2.18557e-08" x2="16" y2="31" stroke="currentColor"/>
+  <line x1="31" y1="16" x2="-4.37114e-08" y2="16" stroke="currentColor"/>
+</svg>

+ 3 - 0
app/templates/partials/menu.html

@@ -0,0 +1,3 @@
+{% for link in site.meta.footer %}
+<a href="{% if not link.url.startswith('http') %}/{% else %}{% endif %}{{ link.url }}" class="{% if not loop.last %}pb1-7 bg-pb0 {% if menu_top %} md-pb0-5 {% endif %}{% endif %}fgc-cyan ttu">{{ link.label}}</a>
+{% endfor %}

+ 18 - 0
app/templates/partials/nav.html

@@ -0,0 +1,18 @@
+<nav class="bdb-gray z2 psf t0 l0 w100 bgc-black x xdr xaic pt0-5 pr0-5 pb0-5 pl0-5 md-pt1 md-pr1-3 md-pb1 md-pl1 lg-pr2">
+  <a href="/" class="logo-eye mra">
+    <svg id="darkfiEyeLogo" aria-labelledby="eyeTitle eyeDesc" role="img" viewBox="0 0 53 45.9" xmlns="http://www.w3.org/2000/svg">
+      <title id="eyeTitle">The DarkFi eye logo.</title>
+      <desc id="eyeDesc">A white, pointing down triangle with at the center the outline of an eye: the center of it has a white circle with a smaller circle inside which is empty.</desc>
+      <g fill="#fff"><path d="m26.5 9.9c-3.2 0-5.7 2.6-5.7 5.7s2.6 5.7 5.7 5.7 5.7-2.6 5.7-5.7-2.5-5.7-5.7-5.7zm0 8.3c-1.4 0-2.6-1.2-2.6-2.6s1.2-2.6 2.6-2.6 2.6 1.2 2.6 2.6-1.2 2.6-2.6 2.6z"/><path d="m0 0 26.5 45.9 26.5-45.9zm26.5 23.8c-5.2 0-9.5-4.1-11.8-8.2 2.2-4.1 6.6-8.2 11.8-8.2s9.5 4.1 11.8 8.2c-2.3 4.1-6.6 8.2-11.8 8.2z"/>
+      </g>
+    </svg>
+  </a>
+  <a href="/" class="logo-hardware psa l50 ttcx">
+    <svg id="darkfiHardwareLogo" aria-labelledby="hardwareTitle hardwareDesc" role="img" viewBox="0 0 174.6 19.8" xmlns="http://www.w3.org/2000/svg">
+      <title id="hardwareTitle">The DarkFi hardware logo</title>
+      <desc id="hardwareDesc">A white text set in a gothic style, spikey typeface with the word Hardware.</desc>
+      <g fill="#fff"><path d="m25.6 4.2 3.5-3.5h-22.2l-4.1 4.4 3.3 4.8-6.1 9.3h12.2l2.8-3.5h-8.3l3.9-5.8-3.7-5.7z"/><path d="m19 7.5-8.4 2.4 6.4.8 7.2 8.5h4.1l3-3.5h-5.1z"/><path d="m47 .7h-14.6l-3.1 3.5h3.8 9.6l1.6 9.1 1.2 6.5 3.4-2.6-2.3-13z"/><path d="m33.1 4.2-2.2 3.4 8.3 12.2 5.1-6.5-4.5 1.2z"/><path d="m53.9 0-3.1 2.9 3.1 2.3v10.5l3.9-2.4v-6.3-2.8z"/><path d="m63.3 19.2 3.4-3.5h-12.8l-3.4 3.5z"/><path d="m68 .7-.6 1.8-6.9-2-2.8 6.5 3.2-2.1 4.9 1.9.9-2.5h8.9.5c5.5.5 7.6 1.4 10.8 9.2l-6.8 2.3c-4.5-2.7-4.3-7.6-4.5-11.5l-3.7 2.5c.3 3.8-.2 11.6 10.7 13.1l4-4.6 4.8-1.3s-3-7.1-5.7-10.1c-3.4-3.9-9.9-3.2-17.7-3.2z"/><path d="m110.8.2-3.6 2.6c-1.3-1.3-3.4-2.1-5.4-2.6l-5.6 4h6.8c1.5 1.6 2.2 2.8 1.2 6.7l-1.6 4.9s1.2-1.1 2.5-2.6l4.7 6.7 1.8-4.1-4.2-5.9c.6-1.2 1.1-2.4 1.1-3.6 0-.7-.2-1.4-.4-2h3.9c1.5 1.6 2.2 2.8 1.2 6.7l-1.6 4.9s5.9-5.2 5.9-9.6c.1-3.8-3.4-5.3-6.7-6.1z"/><path d="m87.3.7 13.6 19.1 1.8-4.1-10.7-15z"/><path d="m136.9.7h-14.5l-3.1 3.5h3.8 9.6l1.6 9.1 1.2 6.5 3.4-2.6-2.3-13z"/><path d="m123.1 4.2-2.2 3.4 8.3 12.2 5.1-6.5-4.5 1.2z"/><path d="m147.4 7v-2.8l-3.8-4.2-3.1 2.9 3.1 2.3v10.5l3.8-2.4z"/><path d="m153 19.2 3.4-3.5h-12.8l-3.5 3.5z"/><path d="m156 4.2 2.5-3.5h-8.4l-2.7 6.3 3.6-2.8z"/><path d="m161.6 2.7 7.6 2.9-4 3.9-.5.9 7.4-3.9 2.6-3-10.2-3.4-6.3 5.5c.9 10.1 5.4 13.4 10.4 14.1l4.3-4.1h-5.6c-4.4-1.3-5.7-9.8-5.7-12.9z"/></g>
+    </svg>
+  </a>
+  {% include "partials/cart.html" %}
+</nav>

+ 35 - 0
app/templates/partials/product-form.html

@@ -0,0 +1,35 @@
+<form class="product-item copy" action="/checkout" method="POST">
+  <input type="hidden" id="page" name="page" value="product">
+  <input type="hidden" id="operation" name="operation" value="add">
+  <input type="hidden" id="redirect_with_items" name="redirect_with_items" value="true">
+  <input type="hidden" id="product_id" name="product_id" value="{{ doc.meta.product_id }}">
+  <input type="hidden" id="product_path" name="product_path" value="{{ doc.meta.path }}">
+  <input type="hidden" id="price" name="price" value="{{ doc.meta.price }}">
+  <input type="hidden" id="quantity" name="quantity" value="1">
+  <input type="hidden" name="csrftoken" value="{{ request.scope.csrftoken() }}">
+  {% if doc.meta.options %}
+  <div class="pb1-7">
+    <fieldset>
+      {% for k,v in doc.meta.options %}
+      {% if v %}
+      <div class="x xdr xw{% if loop.index == 1 %} pb1{% endif %}" style="gap: 0.5rem">
+	<legend class="fs-small ttu pt0-5 pr0-5 pb0-5 pl0-5 {% if k == 'size' %}bd-brown bgc-dark-brown fgc-green{% elif k == 'style' %}bd-blue bgc-dark-blue fgc-light-blue{% endif %}'">{{ k }}</legend>
+	{% for item in v %}
+	<div class="fs-small {% if k == 'size' %}ttu {% endif %}pt0-5 pr0-5 pb0-5 pl0-5">
+	  <input type="radio" id="{{ item }}" name="{{ k }}" value="{{ item }}" class="input-listen curp" required="required"{% if item == product_form[k] %} checked="checked"{% endif %}>
+	  <label for="{{ item }}" class="curp hvc">{{ item }}</label>
+	</div>
+	{% endfor %}
+      </div>
+      {% endif %}
+      {% endfor %}
+    </fieldset>
+  </div>
+  <noscript>
+    <form class="" action="" method="GET">
+      <input type="submit" value="check availability" class="btn-submit fs-big btn-product-available mb1-25">
+    </form>
+  </noscript>
+  {% endif %}
+  <input type="submit" {% if not is_product_available %}value="sold out" disabled {% else %}value="add to cart"{% endif %}{% if not is_product_selectable %} disabled{% endif %} class="btn-submit fs-big{% if not is_product_available %} btn-product-soldout{% elif not is_product_selectable %} btn-product-unselectable{% else %} btn-product-available{% endif %}">
+</form>

+ 57 - 0
app/templates/product.html

@@ -0,0 +1,57 @@
+{% extends "base.html" %}
+
+{% block content %}
+<main class="x xdc md-xdr md-xw">
+  <section class="md-dn bdb-pink">
+    {% with block = doc.blocks.images[0] %}
+    {% include "partials/block-image.html" %}
+    {% endwith %}
+  </section>
+  <section class="w100 md-w50 md-x md-xdc md-xo2 md-bdl-pink hg-x1">
+    <div class="sticky-prod-info">
+      <section class="bdb-gray pt0-75 pr0-75 pb3 pl0-75 copy-h-reset">
+	<div class="bgc-bright-black fgc-dark-gray bd-dark-gray btn-product-code dib copy ">{{ doc.meta.code }}</div>
+	<div class="pt0-5 pb1-7 copy">
+	  <h2 class="pb1">{{ doc.meta.title }}</h2>
+	  <span class="dib pb1">{{ doc.meta.price }} {{ cart.meta.currency }}</span>
+	  {% for block in doc.blocks.texts %}
+	  {% include "partials/block-text.html" %}
+	  {% endfor %}
+	</div>
+	{% include "partials/product-form.html" %}
+      </section>
+      {% if size_guide %}
+      <section class="pt0-75 pr0-75 pb0-75 pl0-75 copy-h-reset product-size-guide bdb-gray">
+	<div class="copy">
+	  {% for block in size_guide.blocks %}
+	  {{  block.value | md | safe }}
+	  {% endfor %}
+	</div>
+      </section>
+      {% endif %}
+    </div>
+    <section class="dn md-db psr md-h100">
+      <div class="psa t0 l50 md-h100 md-bdl-rubin"></div>
+      <div class="dn md-db psa l0 b0 sunset" style="background-image: url('{{ url_for('static', path='images/sunset-green.png')}}')"></div>
+    </section>
+  </section>
+  <section class="w100 md-w50 hg-w1200 md-xo1">
+    {# we set the first image with hide / display class toggles #}
+    {% for block in doc.blocks.images %}
+    {% if loop.first %}{% set img_hide = True %}{% endif %}
+    {% if loop.last %}{% set block_last = True %}{% endif %}
+    {% include "partials/block-image.html" %}
+    {% endfor %}
+  </section>
+  <section class="w100 md-xo3 pt7 copy-h-reset-weight bdt-gray">
+    <h3 class="fs-big pr0-75 pl0-75 pb1-25">More hardware</h3>
+    <div class="dg grid-2 related-products bdt-gray">
+      {% for product in related_products %}
+      {% set block = product.blocks.images[0] %}
+      {% include "partials/grid-product.html" %}
+      {% endfor %}
+    </div>
+  </section>
+</main>
+{% include "partials/footer.html" %}
+{% endblock %}

+ 49 - 0
build-assets.sh

@@ -0,0 +1,49 @@
+#!/bin/bash
+
+set -e
+
+# build css and js files using esbuild
+# we don't install esbuild  as a node npm package as we have simple needs.
+# we instead download a built executable of it, check this URL
+# <https://esbuild.github.io/getting-started/#other-ways-to-install>
+
+if hash ./esbuild 2>/dev/null; then
+    echo "esbuild available! let's proceed..."
+    echo ""
+
+    # styles.css
+    ./esbuild static/css/styles.css \
+	      --entry-names=[name]-[hash] \
+	      --bundle \
+	      --loader:.woff=file \
+	      --loader:.woff2=file \
+	      --asset-names=fonts/[name] \
+	      --target=chrome58,firefox57,safari11,edge16 \
+	      --outdir=static/dist
+
+    # product.js
+    ./esbuild static/js/product.js \
+	      --bundle \
+	      --entry-names=[name]-[hash] \
+	      --target=chrome58,firefox57,safari11,edge16 \
+	      --splitting \
+	      --format=esm \
+	      --outdir=static/dist
+
+    # checkout.js
+    ./esbuild static/js/checkout.js \
+	      --bundle \
+	      --entry-names=[name]-[hash] \
+	      --target=chrome58,firefox57,safari11,edge16 \
+	      --splitting \
+	      --format=esm \
+	      --outdir=static/dist
+
+    echo ""
+    echo "esbuild is done."
+
+else
+    echo "Please download a build of esbuild from here"
+    echo "https://esbuild.github.io/getting-started/#other-ways-to-install"
+    echo "and place it in the root dir of this repo."
+fi

+ 188 - 0
build_image_cache.py

@@ -0,0 +1,188 @@
+from dotenv import load_dotenv
+
+load_dotenv(".env")
+import logging
+import os
+import time
+from multiprocessing import Pool
+from pathlib import Path
+
+import cv2
+import typer
+
+from app.git import git_get_list_of_changed_filepaths_in_last_commit, git_ls_files
+from app.read_settings import read_settings
+
+logger = logging.getLogger(__name__)
+
+
+app = typer.Typer()
+
+
+# TODO this can be done better!
+# we're re-reading the git-repo path instead of assuming
+# we're currently in there, though empirical testing shows
+# doign Path.cwd() returns git repo path
+cwd = Path(__file__).parent
+settings = read_settings(f"{cwd}/settings.toml", False)
+git_repo = Path(settings.git_repo).expanduser()
+
+IMAGE_CACHE_PATH = f"{cwd}/static/images/cache"
+
+SIZES = {
+    ".": [
+        (320,),
+        (640,),
+        (760,),
+        (1024,),
+        (1366,),
+        (1600,),
+        (1920,),
+        (2400,),
+        (2880,),
+        (3840,),
+    ],
+    "products": [
+        (120, 120),
+        (240, 240),  # -- checkout
+        (220, 220),
+        (440, 440),
+        (320,),
+        (640,),  # - product-grid
+        (380,),
+        (760,),
+        (256,),
+        (512,),
+        (340,),
+        (683,),
+        (400,),
+        (800,),
+        (480,),
+        (960,),
+        (460,),
+        (920,),
+        (560,),
+        (1120,),
+        (1024,),  # -- product specific sizes
+        (1200,),
+        (1366,),
+        (1920,),
+        (2400,),
+    ],
+    "pages": [(320,), (640,), (760,), (1024,), (1366,), (1600,), (1920,), (2400,)],
+}
+
+
+def calculate_height_on_width(img, input_width: int) -> int:
+    """Calculate thumbnail's height value of given image by using the
+    desired width.
+    """
+    width_percent = input_width / float(img.shape[1])
+
+    return int((float(img.shape[0]) * float(width_percent)))
+
+
+def make_thumb(size_type: str, img, fp: os.PathLike):
+    """Make a thumbnail image for each defined size."""
+    try:
+        new_filepath = Path(f"{fp.parent.stem}__{fp.stem}_{size_type[0]}{fp.suffix}")
+
+        if Path(f"{IMAGE_CACHE_PATH}/{new_filepath}").exists():
+            pass
+
+        else:
+            # check if size_type is a full tuple or is missing the
+            # height value
+            if len(size_type) == 2:
+                img = cv2.resize(img, size_type)
+            else:
+                img_height = calculate_height_on_width(img, size_type[0])
+                img = cv2.resize(img, (size_type[0], img_height))
+
+            # save it to disk
+            cv2.imwrite(f"{IMAGE_CACHE_PATH}/{new_filepath}", img)
+
+    except OSError as e:
+        logging.error(f"image-resize error => {e}")
+
+
+def image_resize(image: tuple[str, str]):
+    """Resize an image at given preset, if it does not exist yet.
+
+    We're saving each thumbnail in static/images/cache/,
+    in the format /<pa/th>/<image>_<size>.<ext>
+    """
+    template, filepath = image
+    fp = Path(filepath)
+    selected_size = SIZES[template]
+
+    # read image and keep it in memory
+    img = cv2.imread(f"{git_repo}/{filepath}")
+
+    # copy also image as is to the cache
+    copy_filepath = Path(f"{fp.parent.stem}__{fp.stem}_{img.shape[1]}{fp.suffix}")
+
+    if not Path(f"{IMAGE_CACHE_PATH}/{copy_filepath}").exists():
+        cv2.imwrite(f"{IMAGE_CACHE_PATH}/{copy_filepath}", img)
+
+    for size_type in selected_size:
+        if img.shape[1] > size_type[0]:
+            make_thumb(size_type, img, fp)
+
+
+@app.command()
+def build_all_images(repo: str):
+    """Build set of defined thumbnails for each image in the list.
+
+    Ref: <https://stackoverflow.com/a/51822265>
+    """
+    pool = Pool(int(os.getenv("CPU_CORE")))
+
+    repo = Path(repo).expanduser()
+
+    pattern_matching = ["*.png", "*.jpg"]
+    filepaths = git_ls_files(repo, pattern_matching, False)
+
+    images = [
+        (str(Path(filepath).parent).split("/")[0], filepath) for filepath in filepaths
+    ]
+
+    if len(images) > 0:
+        start_time = time.time()
+        pool.map(image_resize, images)
+        logging.info("--- %s seconds ---" % (time.time() - start_time))
+
+    else:
+        logging.info("build-images: no images in the repo.")
+
+
+@app.command()
+def build_images(repo: str, oldrev: str, newrev: str):
+    """Build set of defined thumbnails for each image in the list.
+
+    Ref: <https://stackoverflow.com/a/51822265>
+    """
+    pool = Pool(int(os.getenv("CPU_CORE")))
+
+    repo = Path(repo).expanduser()
+
+    pattern_matching = [".png", ".jpg"]
+    filepaths = git_get_list_of_changed_filepaths_in_last_commit(
+        repo, oldrev, newrev, pattern_matching
+    )
+
+    images = [
+        (str(Path(filepath).parent).split("/")[0], filepath) for filepath in filepaths
+    ]
+
+    if len(images) > 0:
+        start_time = time.time()
+        pool.map(image_resize, images)
+        logging.info("--- %s seconds ---" % (time.time() - start_time))
+
+    else:
+        logging.info("build-images: no image in this commit.")
+
+
+if __name__ == "__main__":
+    app()

+ 31 - 0
darkfi.nginx

@@ -0,0 +1,31 @@
+server {
+    listen 80;
+    server_name <url>;
+
+    root <path/to/website>;
+    access_log </path/to/log>/darkfi-webshop.access.log;
+    error_log </path/to/log>/darkfi-webshop.error.log;
+
+    location / {
+	proxy_pass         http://127.0.0.1:<port>;
+	proxy_http_version 1.1;
+
+	proxy_set_header   Host $http_host;
+	proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
+	proxy_set_header   X-Forwarded-Proto $scheme;
+	proxy_set_header   Upgrade $http_upgrade;
+	proxy_redirect off;
+	proxy_buffering off;
+
+	proxy_set_header   X-Real-IP $remote_addr;
+	proxy_set_header   X-Forwarded-Host $server_name;
+    }
+
+
+    # cache resources under /static
+    location ~* \.(png|jpg|jpeg|gif|svg|ico|woff|woff2|css|js)$ {
+	expires 1y;
+	add_header Cache-Control "public, no-transform";
+    }
+
+}

+ 5 - 0
dev-server.sh

@@ -0,0 +1,5 @@
+#!/bin/bash
+
+set -e
+
+uvicorn app.main:app --reload --port 5014

+ 122 - 0
devenv.lock

@@ -0,0 +1,122 @@
+{
+  "nodes": {
+    "devenv": {
+      "locked": {
+        "dir": "src/modules",
+        "lastModified": 1725637114,
+        "owner": "cachix",
+        "repo": "devenv",
+        "rev": "c31e347a96dbb7718a0279afa993752a7dfc6a39",
+        "treeHash": "e0dfcbbfb0974603336900406b364bd4d1308fa4",
+        "type": "github"
+      },
+      "original": {
+        "dir": "src/modules",
+        "owner": "cachix",
+        "repo": "devenv",
+        "type": "github"
+      }
+    },
+    "flake-compat": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1696426674,
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
+        "treeHash": "2addb7b71a20a25ea74feeaf5c2f6a6b30898ecb",
+        "type": "github"
+      },
+      "original": {
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "type": "github"
+      }
+    },
+    "gitignore": {
+      "inputs": {
+        "nixpkgs": [
+          "pre-commit-hooks",
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1709087332,
+        "owner": "hercules-ci",
+        "repo": "gitignore.nix",
+        "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
+        "treeHash": "ca14199cabdfe1a06a7b1654c76ed49100a689f9",
+        "type": "github"
+      },
+      "original": {
+        "owner": "hercules-ci",
+        "repo": "gitignore.nix",
+        "type": "github"
+      }
+    },
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1716977621,
+        "owner": "cachix",
+        "repo": "devenv-nixpkgs",
+        "rev": "4267e705586473d3e5c8d50299e71503f16a6fb6",
+        "treeHash": "6d9f1f7ca0faf1bc2eeb397c78a49623260d3412",
+        "type": "github"
+      },
+      "original": {
+        "owner": "cachix",
+        "ref": "rolling",
+        "repo": "devenv-nixpkgs",
+        "type": "github"
+      }
+    },
+    "nixpkgs-stable": {
+      "locked": {
+        "lastModified": 1725407940,
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "6f6c45b5134a8ee2e465164811e451dcb5ad86e3",
+        "treeHash": "f65d5344b23cfbe3695140b602bc463349b26638",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-24.05",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "pre-commit-hooks": {
+      "inputs": {
+        "flake-compat": "flake-compat",
+        "gitignore": "gitignore",
+        "nixpkgs": [
+          "nixpkgs"
+        ],
+        "nixpkgs-stable": "nixpkgs-stable"
+      },
+      "locked": {
+        "lastModified": 1725513492,
+        "owner": "cachix",
+        "repo": "pre-commit-hooks.nix",
+        "rev": "7570de7b9b504cfe92025dd1be797bf546f66528",
+        "treeHash": "4b46d77870afecd8f642541cb4f4927326343b59",
+        "type": "github"
+      },
+      "original": {
+        "owner": "cachix",
+        "repo": "pre-commit-hooks.nix",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "devenv": "devenv",
+        "nixpkgs": "nixpkgs",
+        "pre-commit-hooks": "pre-commit-hooks"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}

+ 13 - 0
devenv.nix

@@ -0,0 +1,13 @@
+{ pkgs, ... }:
+
+{
+  packages = [
+    pkgs.python312Packages.pip
+    pkgs.stripe-cli
+  ];
+
+  languages.python = {
+    enable = true;
+    package = pkgs.python312;
+  };
+}

+ 27 - 0
env.sample

@@ -0,0 +1,27 @@
+ENV=<development|production>
+
+CACHE=<enable|disable>
+
+CSRF_SECRET_KEY=<generated key with Python, see README>
+SESSION_SECRET_KEY=<generated key with Python, see README>
+
+STRIPE_SECRET_KEY=<Stripe API key>
+STRIPE_ENDPOINT_SECRET=<Stripe webhook secret> 
+
+NOWPAYMENTS_API_KEY=<NOWPayments API key>
+NOWPAYMENTS_SANDBOX_IPN=<NOWPayments IPN>
+NOWPAYMENTS_SANDBOX_API_KEY=<NOWPayments Sandbox API key>
+NOWPAYMENTS_SANDBOX_IPN=<NOWPayments Sandbox IPN>
+
+MAIL_USERNAME=<email address>
+MAIL_PASSWORD=<email password>
+MAIL_FROM=<email sender address>
+MAIL_FROM_NAME=<email sender name>
+MAIL_PORT=<email port>
+MAIL_SERVER=<email server>
+MAIL_STARTTLS=<True|False>
+MAIL_SSL_TLS=<True|False>
+MAIL_USE_CREDENTIALS=<True|False>
+MAIL_VALIDATE_CERTS=<True|False>
+
+CPU_CORE=<set computer's number of CPU cores, on Linux run `nproc --all`>

+ 32 - 0
post-receive

@@ -0,0 +1,32 @@
+#!/bin/sh
+# automatically configured by git-annex
+if git annex post-receive --help >/dev/null 2>&1; then git annex post-receive; fi
+
+echo "git post-receive hook: starting"
+
+GIT_DIR=.git 
+WORKING_REPO=../content
+
+CWD=$(pwd)
+PYTHON='<path/to/python-virtual-env>'
+WWW_SCRIPT='<path/to/build_image_cache.py script>'
+CPU_CORE=<number of CPU cores available, run nproc --all on Linux>
+
+while read oldrev newrev ref
+do
+    # synchronize git checkout folder
+    
+    BRANCH=`echo $ref | cut -d/ -f3`
+    # avoid error with git-annex `synced` branches
+    if ! [ $BRANCH = 'synced' ]; then
+	cd $WORKING_REPO \
+		&& git pull origin $BRANCH \
+		&& git annex sync --content
+    fi
+done
+
+# rebuild all changed images
+echo "rebuild updated images..."
+CPU_CORE=$CPU_CORE $PYTHON $WWW_SCRIPT build-all-images $WORKING_REPO
+
+echo "git post-receive hook: ending"

+ 23 - 0
pyproject.toml

@@ -0,0 +1,23 @@
+[project]
+name = "hardware.dark.fi"
+description = "Webshop for the Dark Fi project"
+requires-python = ">3.12"
+dynamic = ["version"]
+authors = [{name = "André Fincato", email = "work@andrefincato.info"}]
+readme = "README.md"
+dependencies = [
+
+]
+
+[tool.ruff]
+fix = true
+
+[tool.ruff.lint]
+extend-select = [
+   "D",   # pydocstyle
+]
+ignore = ["D100", "D101", "D104", "D205", "D400", "D401", "D404", "D415", "E402", "E501", "E203"]
+
+[tool.ruff.format]
+# Enable reformatting of code snippets in docstrings.
+docstring-code-format = true

+ 87 - 0
requirements.txt

@@ -0,0 +1,87 @@
+aiosmtplib==2.0.2
+annotated-types==0.6.0
+anyio==4.3.0
+arrow==1.3.0
+asgi-csrf==0.9
+attrs==23.2.0
+blinker==1.8.2
+certifi==2024.2.2
+charset-normalizer==3.3.2
+click==8.1.7
+dnspython==2.6.1
+email_validator==2.1.1
+et-xmlfile==1.1.0
+fastapi==0.111.0
+fastapi-cli==0.0.2
+fastapi-mail==1.4.1
+greenlet==3.0.3
+gunicorn==22.0.0
+h11==0.14.0
+httpcore==1.0.5
+httptools==0.6.1
+httpx==0.27.0
+idna==3.7
+iniconfig==2.0.0
+itsdangerous==2.2.0
+Jinja2==3.1.3
+lxml==5.2.2
+markdown-it-py==3.0.0
+MarkupSafe==2.1.5
+mccabe==0.7.0
+mdit-py-plugins==0.4.1
+mdurl==0.1.2
+mypy-extensions==1.0.0
+nh3==0.2.18
+numpy==2.0.0
+opencv-python==4.10.0.84
+openpyxl==3.1.5
+orjson==3.10.2
+outcome==1.3.0.post0
+packaging==24.0
+pathspec==0.12.1
+pillow==10.3.0
+platformdirs==4.2.2
+playwright==1.46.0
+pluggy==1.5.0
+pycodestyle==2.12.0
+pycountry==23.12.11
+pydantic==2.7.1
+pydantic-settings==2.3.4
+pydantic_core==2.18.2
+pydocstyle==6.3.0
+pyee==11.1.0
+pyflakes==3.2.0
+Pygments==2.17.2
+pytest==8.2.0
+pytest-asyncio==0.23.8
+pytest-base-url==2.1.0
+pytest-playwright==0.5.1
+python-dateutil==2.9.0.post0
+python-dotenv==1.0.1
+python-multipart==0.0.9
+python-slugify==8.0.4
+PyYAML==6.0.1
+requests==2.32.3
+rich==13.7.1
+ruff==0.5.5
+shellingham==1.5.4
+shortuuid==1.0.13
+six==1.16.0
+sniffio==1.3.1
+snowballstemmer==2.2.0
+sortedcontainers==2.4.0
+starlette==0.37.2
+starsessions==2.1.3
+stripe==9.11.0
+text-unidecode==1.3
+tomli==2.0.1
+trio==0.26.0
+typer==0.12.3
+types-python-dateutil==2.9.0.20240316
+typing_extensions==4.11.0
+ujson==5.9.0
+urllib3==2.2.1
+uvicorn==0.29.0
+uvloop==0.19.0
+watchfiles==0.21.0
+websockets==12.0

+ 11 - 0
server.sh

@@ -0,0 +1,11 @@
+#!/bin/bash
+
+set -e
+
+gunicorn app.main:app \
+         --workers 4 \
+         --worker-class uvicorn.workers.UvicornWorker \
+         --bind 127.0.0.1:5009
+
+
+

+ 14 - 0
settings.sample.toml

@@ -0,0 +1,14 @@
+git_repo = "<path/to/repo>"
+document_match = [".md"]
+document_exclude = ["README.md", "orders/index.md", "checkout/index.md", "checkout/email.md"]
+block_types = ["text", "image", "order"]
+
+[local_db]
+filepath = "orders/index.md"
+
+[order_upload]
+filepath = "orders/upload/order-upload-template-empty.xlsx"
+
+[currency]
+default = "CHF"
+now_payments = "EUR"

binární
static/android-chrome-192x192.png


binární
static/android-chrome-512x512.png


binární
static/apple-touch-icon.png


+ 1549 - 0
static/css/styles.css

@@ -0,0 +1,1549 @@
+@font-face {
+    font-family: "IBM Plex Mono Light";
+    src: url("../fonts/IBMPlexMono-Light.woff2") format("woff2"),
+         url("../fonts/IBMPlexMono-Light.woff") format("woff"),
+    font-style: normal;
+    font-weight: 100;
+    font-display: swap;
+}
+
+@font-face {
+    font-family: "IBM Plex Mono Light Italic";
+    src: url("../fonts/IBMPlexMono-LightItalic.woff2") format("woff2"),
+         url("../fonts/IBMPlexMono-LightItalic.woff") format("woff"),
+    font-style: italic;
+    font-weight: 100;
+    font-display: swap;
+}
+
+@font-face {
+    font-family: "Inter Bold";
+    src: url("../fonts/Inter-Bold.woff2") format("woff2"),
+         url("../fonts/Inter-Bold.woff") format("woff"),
+    font-style: normal;
+    font-weight: 900;
+    font-display: swap;
+}
+
+@font-face {
+    font-family: "Inter Bold Italic";
+    src: url("../fonts/Inter-BoldItalic.woff2") format("woff2"),
+         url("../fonts/Inter-BoldItalic.woff") format("woff"),
+    font-style: italic;
+    font-weight: 900;
+    font-display: swap;
+}
+
+@font-face {
+    font-family: "Weiss Rund Gotisch";
+    src: url("../fonts/weissrundgotisch.regular.woff2") format("woff2"),
+         url("../fonts/weissrundgotisch.regular.woff") format("woff"),
+    font-style: normal;
+    font-weight: 400;
+    font-display: swap;
+}
+
+:root {
+    --nav-height: 35px;
+    --c-white: #ffffff;
+    --c-anon-gray: #a5a5a5;
+    --c-light-gray: #dbdbdb;
+    --c-gray: #b7b7b7;
+    --c-pink: #ffbcfc;
+    --c-dark-bright-brown: #241b18;
+    --c-dark-rubin: #3d230b;
+    --c-rubin: #610000;
+    --c-dark-red: #520000;
+    --c-deep-brown: #2B2222;
+    --c-terre: #AA9494;
+    --c-green: #8dff98;
+    --c-forest-green: #4c8042;
+    --c-light-green: #f2ffcd;
+    --c-light-blue: #bffbff;
+    --c-another-blue: #80d1ff;
+    --c-cyan: #00ffff;
+    --c-dark-teal: #005669;
+    --c-bluegreen: #002f10;
+    --c-dark-green: #002C29;
+    --c-dark-bluegreen: #001b1d;
+    --c-blue: #00114c;
+    --c-dark-blue: #00111d;
+    --c-brown: #2f2a00;
+    --c-dark-brown: #1a1d00;
+    --c-gray: #4b4b4b;
+    --c-dark-gray: #555353;
+    --c-bright-black: #020713;
+    --c-gaow: #160E0E;
+    --c-blackish: #11181d;
+    --c-dark-black: #090d10;
+    --c-black: #090909;
+    --c-blackest: #000000;
+    --c-yet-gray: #292929;
+    --f-mono: "IBM Plex Mono Light", monospace;
+    --f-mono-italic: "IBM Plex Mono Light Italic", monospace;
+    --f-sans: "Inter Bold", sans-serif;
+    --f-sans-italic: "Inter Bold Italic", sans-serif;
+    --f-gothic: "Weiss Rund Gotisch", serif;
+}
+
+@media screen and (min-width: 760px) {
+    :root {
+	--nav-height: 47px;
+    }
+}
+
+/* basic reset */
+
+html {
+    box-sizing: border-box;
+    scroll-padding-top: var(--nav-height);    
+}
+
+*, *:before, *:after {
+    box-sizing: inherit
+}
+
+html, body { 
+    scroll-behavior: smooth;
+}
+
+html, body,
+h1, h2, h3, h4, h5, h6,
+p,
+ol, ul, li,
+figure {
+    margin: 0;
+    padding: 0
+}
+
+figure img {
+    display: block;
+}
+
+ol, ul {
+    padding-left: 1rem;
+}
+
+img {
+    max-width: 100%;
+    height: auto;
+}
+
+button {
+    font-family: inherit;
+    font-size: inherit;
+    border: none;
+    border-radius: 0;
+    margin: 0;
+    padding: 0;
+    background: none;
+    color: inherit;
+    cursor: pointer;
+}
+
+
+/* basic typographic styles */
+
+body {
+  -webkit-overflow-scrolling: touch;
+  -ms-overflow-style: none;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  text-rendering: optimizeLegibility;
+  width: 100%;
+  height: 100%;
+}
+
+html {
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  color: var(--c-white);
+  font-size: 12px;
+  font-family: var(--f-mono);
+}
+
+a {
+    text-decoration: none;
+    color: inherit;
+}
+
+input,
+select,
+textarea {
+    background-color: inherit;
+    color: inherit;
+    border: 0;
+    font-family: inherit;
+    font-style: inherit;
+    font-weight: inherit;
+    font-size: inherit;
+    margin: 0;
+    padding: 0;
+}
+
+input::placeholder,
+textarea::placeholder {
+    opacity: 1;
+    color: #9f879b;
+}
+
+fieldset {
+    border: 0;
+    padding: 0;
+}
+
+/* typography */
+
+.fs-small {
+    font-size: 10px;
+}
+
+.fs-medium {
+    font-size: 1rem;
+}
+
+.fs-biggish {
+    font-size: 14px;
+}
+
+.fs-big {
+    font-size: 15px;
+}
+
+.fs-bigger {
+    font-size: 16px;
+}
+
+.fs-large {
+    font-size: 18px;
+}
+
+.fs-larger {
+    font-size: 20px;
+}
+
+.fs-huge {
+    font-size: 24px;
+}
+
+.fs-wide {
+    font-size: 30px;
+}
+
+.ff-sans {
+    font-family: var(--f-sans);
+}
+
+.ff-sans em,
+.ff-sans italic {
+    font-family: var(--f-sans-italic);
+    font-style: normal;
+}
+
+.ff-mono em,
+.ff-mono italic {
+    font-family: var(--f-mono-italic);
+    font-style: normal;
+}
+
+
+@media screen and (min-width: 540px) {
+    .sm-fs-medium {
+	font-size: 1rem;
+    }
+
+    .sm-fs-biggish {
+	font-size: 14px;
+    }    
+
+    .sm-fs-big {
+	font-size: 15px;
+    }
+
+    .sm-fs-large {
+	font-size: 18px;
+    }
+
+    .sm-fs-larger {
+	font-size: 20px;
+    }    
+
+    .sm-fs-huge {
+	font-size: 24px;
+    }
+}
+
+@media screen and (min-width: 1280px) {
+    .bg-fs-big {
+	font-size: 15px;
+    }
+}
+
+.tac {
+    text-align: center;
+}
+
+.tar {
+    text-align: right;
+}
+
+@media screen and (min-width: 540px) {
+    .sm-tar {
+	text-align: right;
+    }
+}
+
+.copy h3,
+.copy h4,
+.copy h5,
+.copy h6,
+.copy p,
+.copy table {
+    max-width: 700px;
+}
+
+.copy-h-reset-weight h1,
+.copy-h-reset-weight h2,
+.copy-h-reset-weight h3,
+.copy-h-reset-weight h4,
+.copy-h-reset-weight h5,
+.copy-h-reset-weight h6 {
+    font-weight: inherit;
+}
+
+.copy-h-reset h1,
+.copy-h-reset h2,
+.copy-h-reset h3,
+.copy-h-reset h4,
+.copy-h-reset h5,
+.copy-h-reset h6 {
+    font-size: inherit;
+    font-style: inherit;
+}
+
+.copy-h h1,
+.copy-h h2 {
+    font-size: 30px;
+    font-family: var(--f-gothic);
+    padding: 15px 20px;
+    border-bottom: 1px solid var(--c-gray);
+}
+
+@media screen and (min-width: 1920px) {
+    .copy-h h1,
+    .copy-h h2 {
+	padding: 30px 50px;
+    }
+
+    .copy-h-front h1,
+    .copy-h-front h2,
+    .copy-h-page h1,
+    .copy-h-page h2 {
+	padding: 2.5rem 4.2rem;
+    }
+}
+
+.copy-h-page h3,
+.copy-h-page h4,
+.copy-h-page h5,
+.copy-h-page h6 {
+    font-size: inherit;
+}
+
+.copy-page h3,
+.copy-page h4,
+.copy-page h5,
+.copy-page h6, 
+.copy-page p {
+    padding: 0.75rem 0.75rem 2.5rem 0.7rem;
+}
+
+.copy-page table {
+    margin: 0 0.75rem 2.45em 0.7rem;
+}
+
+.copy-page table td {
+    width: 7.5rem;
+}
+
+.copy a {
+    color: var(--c-cyan);
+}
+
+.ttu {
+    text-transform: uppercase;
+}
+
+.hvc:hover {
+    color: var(--c-green);
+}
+
+.checkout-image {
+    width: 120px;
+}
+
+@media screen and (min-width: 540px) {
+    .checkout-image {
+	width: 220px;
+    }
+}
+
+.img-page img {
+    max-height: 1000px;
+}
+
+@media screen and (min-width: 2400px) {
+    .img-page img {
+	max-height: 12000px;
+    }
+}
+
+/* borders */
+
+.bd-white {
+    border: 1px solid var(--c-white);
+}
+
+.bdb-pink {
+    border-bottom: 1px solid var(--c-pink);
+}
+
+.bdb-dark-green {
+    border-bottom: 1px solid var(--c-dark-green);
+}
+
+.bd-dark-rubin {
+    border: 1px solid var(--c-dark-rubin);
+}
+
+.bdt-dark-red {
+    border-top: 1px solid var(--c-dark-red);
+}
+
+.bdb-dark-red {
+    border-bottom: 1px solid var(--c-dark-red);
+}
+
+.bd-yet-gray {
+    border: 1px solid var(--c-yet-gray);
+}
+
+.bd-deep-brown {
+    border: 1px solid var(--c-deep-brown);
+}
+
+.bdt-gray {
+    border-top: 1px solid var(--c-gray);
+}
+
+.bdb-gray {
+    border-bottom: 1px solid var(--c-gray);
+}
+
+.bd-dark-gray {
+    border: 1px solid var(--c-dark-gray);
+}
+
+.bd-brown {
+    border: 1px solid var(--c-brown);
+}
+
+.bd-blue {
+    border: 1px solid var(--c-blue);
+}
+
+.bd-bluegreen {
+    border: 1px solid var(--c-bluegreen);
+}
+
+@media screen and (min-width: 760px) {
+    .md-bdl-pink {
+	border-left: 1px solid var(--c-pink);
+    }
+
+    .md-bdl-rubin {
+	border-left: 1px solid var(--c-rubin);
+    }
+}
+
+@media screen and (min-width: 1280px) {
+    .bg-bdr-gray {
+	border-right: 1px solid var(--c-gray);
+    }
+
+    .bg-bdl-gray {
+	border-left: 1px solid var(--c-gray);
+    }    
+}
+
+@media screen and (min-width: 1920px) {
+    .lg-bdr-gray {
+	border-right: 1px solid var(--c-gray);
+    }
+
+    .lg-bdl-gray {
+	border-left: 1px solid var(--c-gray);
+    }
+
+    .lg-bdl-pink {
+	border-left: 1px solid var(--c-pink);
+    }
+}
+
+.brad-6 {
+    border-radius: 6px;
+}
+
+/* display */
+
+.vh {
+    clip: rect(0 0 0 0);
+    clip-path: inset(50%);
+    height: 1px;
+    overflow: hidden;
+    position: absolute;
+    white-space: nowrap;
+    width: 1px;
+}
+
+
+.dn {
+    display: none;
+}
+
+.dib {
+    display: inline-block;
+}
+
+.db {
+    display: block;
+}
+
+.x {
+    display: flex;
+}
+
+.xw {
+    flex-wrap: wrap;
+}
+
+.xdr {
+    flex-direction: row;
+}
+
+.xdc {
+    flex-direction: column;
+}
+
+.xaic {
+    align-items: center;
+}
+
+.xaib {
+    align-items: baseline;
+}
+
+.xjsb {
+    justify-content: space-between;
+}
+
+.xo1 {
+    order: 1;
+}
+
+.xo2 {
+    order: 2;
+}
+
+.xo3 {
+    order: 3;
+}
+
+.x1 {
+    flex: 1;
+}
+
+.g-4-16 {
+    gap: 4.16rem;
+}
+
+@media screen and (min-width: 400px) {
+    .tn-xdr {
+	flex-direction: row;	
+    }
+}
+
+
+@media screen and (min-width: 540px) {
+    .sm-xaic {
+	align-items: center;
+    }    
+
+    .sm-xo1 {
+	order: 1;
+    }
+
+    .sm-xo2 {
+	order: 2;
+    }
+
+    .sm-xo3 {
+	order: 3;
+    }
+}
+
+@media screen and (min-width: 760px) {
+    .md-dn {
+	display: none;
+    }
+
+    .md-db {
+	display: block;
+    }
+
+    .md-x {
+	display: flex;
+    }
+
+    .md-xdc {
+	flex-direction: column;
+    }
+
+    .md-xdr {
+	flex-direction: row;
+    }
+    
+    .md-xw {
+	flex-wrap: wrap;
+    }
+
+    .md-xo1 {
+	order: 1;
+    }
+
+    .md-xo2 {
+	order: 2;
+    }
+
+    .md-xo3 {
+	order: 3;
+    }
+}
+
+@media screen and (min-width: 1280px) {
+    .bg-xw {
+	flex-wrap: wrap;
+    }
+
+    .bg-xdr {
+	flex-direction: row;
+    }
+
+    .bg-xdc {
+	flex-direction: column;
+    }
+
+    .bg-xjsb {
+	justify-content: space-between;
+    }    
+
+    .bg-xo1 {
+	order: 1;
+    }
+
+    .bg-xo2 {
+	order: 2;
+    }
+
+    .bg-xo3 {
+	order: 3;
+    }    
+}
+
+@media screen and (min-width: 1920px) {
+    .lg-x {
+	display: flex;
+    }
+
+    .lg-xdr {
+	flex-direction: row;
+    }
+    
+    .lg-xw {
+	flex-wrap: wrap;
+    }
+	
+    .lg-xdc {
+	flex-direction: column;
+    }
+
+    .lg-xo1 {
+	order: 1;
+    }
+
+    .lg-xo2 {
+	order: 2;
+    }
+
+    .lg-xo3 {
+	order: 3;
+    }
+
+    .lg-xjsb {
+	justify-content: space-between;
+    }
+}
+
+.dg {
+    display: grid;
+}
+
+/* position */
+
+.z1 {
+    z-index: 1;
+}
+
+.z2 {
+    z-index: 2;
+}
+
+.psr {
+    position: relative;
+}
+
+.psf {
+    position: fixed;
+}
+
+.psa {
+    position: absolute;
+}
+
+.t0 {
+    top: 0;
+}    
+
+.b0 {
+    bottom: 0;
+}
+
+.l0 {
+    left: 0;
+}
+
+.t50 {
+    top: 50%;
+}
+
+.l50 {
+    left: 50%;
+}
+
+.ttcx {
+    transform: translateX(-50%);
+}
+
+.ttcc {
+    transform: translate(-50%, -50%);
+}
+
+.mta {
+    margin-top: auto;
+}
+
+.mla {
+    margin-left: auto;
+}
+
+.mra {
+    margin-right: auto;
+}
+
+.mt1 {
+    margin-top: 1rem;
+}
+
+.mr1 {
+    margin-right: 1rem;
+}
+
+.mr0-5 {
+    margin-right: 0.5rem;
+}
+
+.ml0-5 {
+    margin-left: 0.5rem;
+}
+
+.ml1 {
+    margin-left: 1rem;
+}
+
+.mb1-25 {
+    margin-bottom: 1.25rem;
+}
+
+.mb4-25 {
+    margin-bottom: 4.25rem;
+}
+
+/* spacing */
+
+.pt0-25 {
+    padding-top: 0.25rem;
+}
+
+.pr0-25 {
+    padding-right: 0.25rem;
+}
+
+.pb0-25 {
+    padding-bottom: 0.25rem;
+}
+
+.pl0-25 {
+    padding-left: 0.25rem;
+}
+
+.pt0-5 {
+    padding-top: 0.5rem;
+}
+
+.pr0-5 {
+    padding-right: 0.5rem;
+}
+
+.pb0-5 {
+    padding-bottom: 0.5rem;
+}
+
+.pl0-5 {
+    padding-left: 0.5rem;
+}
+
+.pt0-75 {
+    padding-top: 0.75rem;
+}
+
+.pr0-75 {
+    padding-right: 0.75rem;
+}
+
+.pb0-75 {
+    padding-bottom: 0.75rem;
+}
+
+.pl0-75 {
+    padding-left: 0.75rem;
+}
+
+.pt0-85 {
+    padding-top: 0.85rem;
+}
+
+.pr0-85 {
+    padding-right: 0.85rem;
+}
+
+.pb0-85 {
+    padding-bottom: 0.85rem;
+}
+
+.pl0-85 {
+    padding-left: 0.85rem;
+}
+
+.pt1 {
+    padding-top: 1rem;
+}
+
+.pb1 {
+    padding-bottom: 1rem;
+}
+
+.pb1-25 {
+    padding-bottom: 1.25rem;
+}
+
+.pt1-7 {
+    padding-top: 1.7rem;
+}
+
+.pb1-7 {
+    padding-bottom: 1.7rem;
+}
+
+.pb2 {
+    padding-bottom: 2rem;
+}
+
+.pt3-5 {
+    padding-top: 3.5rem;
+}
+
+.pb3 {
+    padding-bottom: 3rem;
+}
+
+.pt4-25 {
+    padding-top: 4.25rem;
+}
+
+.pb4-25 {
+    padding-bottom: 4.25rem;
+}
+
+.pt4-75 {
+    padding-top: 4.75rem;
+}
+
+.pb4-75 {
+    padding-bottom: 4.75rem;
+}
+
+.pb5-5 {
+    padding-bottom: 5.5rem;
+}
+
+.pt7 {
+    padding-top: 7rem;
+}
+
+.pb7 {
+    padding-bottom: 7rem;
+}
+
+@media screen and (min-width: 540px) {
+    .sm-pr0 {
+	padding-right: 0;
+    }
+
+    .sm-pl0 {
+	padding-left: 0;
+    }
+}
+
+@media screen and (min-width: 760px) {
+    .md-ml1-25 {
+	margin-left: 1.25rem;
+    }
+
+    .md-pt0-5 {
+	padding-top: 0.5rem;
+    }
+
+    .md-pr0-5 {
+	padding-right: 0.5rem;
+    }
+
+    .md-pb0-5 {
+	padding-bottom: 0.5rem;
+    }
+
+    .md-pl0-5 {
+	padding-left: 0.5rem;
+    }
+
+    .md-pt1 {
+	padding-top: 1rem;
+    }
+
+    .md-pr1 {
+	padding-right: 1rem;
+    }    
+
+    .md-pb1 {
+	padding-bottom: 1rem;
+    }
+
+    .md-pl1 {
+	padding-left: 1rem;
+    }
+
+    .md-pr1-3 {
+	padding-right: 1.3rem;
+    }
+    
+    .md-pl1-3 {
+	padding-left: 1.3rem;
+    }
+
+    .md-pt3-35 {
+	padding-top: 3.35rem;
+    }
+    
+    .md-pr3-35 {
+	padding-right: 3.35rem;
+    }
+    
+    .md-pb3-35 {
+	padding-bottom: 3.35rem;
+    }
+    
+    .md-pl3-35 {
+	padding-left: 3.35rem;
+    }
+
+    .md-pt4-2 {
+	padding-top: 4.2rem;
+    }
+    
+    .md-pr4-2 {
+	padding-right: 4.2rem;
+    }
+    
+    .md-pb4-2 {
+	padding-bottom: 4.2rem;
+    }
+    
+    .md-pl4-2 {
+	padding-left: 4.2rem;
+    }
+}
+
+@media screen and (min-width: 1280px) {
+    .bg-pb0 {
+	padding-bottom: 0;
+    }
+
+    .bg-pt1-25 {
+	padding-top: 1.25rem;
+    }
+
+    .bg-pr1-25 {
+	padding-right: 1.25rem;
+    }
+
+    .bg-pb1-25 {
+	padding-bottom: 1.25rem;
+    }
+
+    .bg-pl1-25 {
+	padding-left: 1.25rem;
+    }
+    
+    .bg-pt1-7 {
+	padding-top: 1.7rem;
+    }
+
+    .bg-pr1-7 {
+	padding-right: 1.7rem;
+    }
+
+    .bg-pb1-7 {
+	padding-bottom: 1.7rem;
+    }
+
+    .bg-pl1-7 {
+	padding-left: 1.7rem;
+    }
+}
+
+@media screen and (min-width: 1280px) {
+    .bg-pt4-2 {
+	padding-top: 4.2rem;
+    }
+
+    .bg-pr8-3 {
+	padding-right: 8.3rem;
+    }
+
+    .bg-pb4-2 {
+	padding-bottom: 4.2rem;
+    }
+
+    .bg-pl4-2 {
+	padding-left: 4.2rem;
+    }    
+
+    .bg-pl8-3 {
+	padding-left: 8.3rem;
+    }    
+}
+
+@media screen and (min-width: 1920px) {
+    .lg-pb0 {
+	padding-bottom: 0;
+    }    
+
+    .lg-pr2 {
+	padding-right: 2;
+    }
+
+    .lg-pt0-42 {
+	padding-top: 0.42rem;
+    }
+
+    .lg-pr0-42 {
+	padding-right: 0.42rem;
+    }
+
+    .lg-pb0-42 {
+	padding-bottom: 0.42rem;
+    }
+
+    .lg-pl0-42 {
+	padding-left: 0.42rem;
+    }    
+
+    .lg-pt4-2 {
+	padding-top: 4.2rem;
+    }
+    
+    .lg-pr4-2 {
+	padding-right: 4.2rem;
+    }
+    
+    .lg-pb4-2 {
+	padding-bottom: 4.2rem;
+    }
+    
+    .lg-pl4-2 {
+	padding-left: 4.2rem;
+    }
+
+    .lg-pt8-3 {
+	padding-top: 8.3rem;
+    }
+    
+    .lg-pr8-3 {
+	padding-right: 8.3rem;
+    }
+    
+    .lg-pb8-3 {
+	padding-bottom: 8.3rem;
+    }
+    
+    .lg-pl8-3 {
+	padding-left: 8.3rem;
+    }
+}
+
+/* logos */
+
+.logo-eye {
+    width: 27px;
+    height: 22.7px;
+}
+
+.logo-hardware {
+    width: 120px;
+    height: 14px;
+}
+
+@media screen and (min-width: 760px) {
+    .logo-hardware {
+	width: 160px;
+	height: 18px;
+    }
+}
+
+/* colors */
+
+.bgc-white {
+    background-color: var(--c-white);
+}
+
+.bgc-gr-black {
+    background: linear-gradient(180deg, var(--c-blackish) 0%, var(--c-dark-black) 100%);
+    background: var(--c-dark-black);
+}
+
+.bgc-black {
+    background-color: var(--c-black);
+}
+
+.bgc-blackest {
+    background-color: var(--c-blackest);
+}
+
+.bgc-gaow {
+    background-color: var(--c-gaow);
+}
+
+.bgc-bright-black {
+    background-color: var(--c-bright-black);
+}
+
+.fgc-terre {
+    color: var(--c-terre);
+}
+
+.fgc-gray {
+    color: var(--c-gray);
+}
+
+.fgc-dark-gray {
+    color: var(--c-dark-gray);
+}
+
+.bgc-dark-brown {
+    background-color: var(--c-dark-brown);
+}
+
+.bgc-dark-bright-brown {
+    background-color: var(--c-dark-bright-brown);
+}
+
+.fgc-light-gray {
+    color: var(--c-light-gray);
+}
+
+.fgc-anon-gray {
+    color: var(--c-anon-gray);
+}
+
+.fgc-green {
+    color: var(--c-green);
+}
+
+.fgc-light-blue {
+    color: var(--c-light-blue);
+}
+
+.bgc-dark-blue {
+    background-color: var(--c-dark-blue);
+}
+
+.bgc-dark-bluegreen {
+    background-color: var(--c-dark-bluegreen);
+}
+
+.fgc-cyan {
+    color: var(--c-cyan);
+}
+
+
+/* --- */
+
+.btn-product-code {
+    padding: 2px 4px 3px 4px;
+}
+
+.btn-checkout-remove {
+    padding: 5px 7px;
+}
+
+.btn-submit {
+    padding: 8px 12px 10px 12px;
+}
+
+.btn-product-soldout {
+    border: 1px solid var(--c-dark-rubin);
+    background-color: var(--c-dark-bright-brown);
+    color: var(--c-light-gray);
+}
+
+.btn-product-unselectable {
+    border: 1px solid var(--c-bluegreen);
+    background-color: var(--c-dark-black);
+    color: var(--c-light-gray);
+}
+
+.btn-product-available {
+    cursor: pointer;
+    border: 1px solid var(--c-bluegreen);
+    background-color: var(--c-dark-bluegreen);
+    color: var(--c-cyan);    
+}
+
+.btn-product-available:hover {
+    cursor: pointer;
+    border: 1px solid var(--c-dark-teal);
+    background-color: var(--c-blackish);
+    color: var(--c-another-blue);
+}
+
+.payment-buttons {
+    gap: 1rem;
+}
+
+.btn-stripe {
+    width: 88px;
+    height: 42px
+}
+
+.btn-nowpayments {
+    width: 170px;
+    height: 42px
+}
+
+@media screen and (min-width: 540px) {
+    .btn-stripe {
+	width: 116px;
+	height: 55px
+    }
+
+    .btn-nowpayments {
+	width: 220px;
+	height: 55px
+    }
+
+}
+
+.product-size-guide h1,
+.product-size-guide h2,
+.product-size-guide h3,
+.product-size-guide h4,
+.product-size-guide h5,
+.product-size-guide h6 {
+    color: var(--c-another-blue);
+    text-transform: uppercase;
+    padding-bottom: 1.7rem;
+}
+
+.product-size-guide table {
+    width: 100%;
+}
+
+.copy table {
+    table-layout: fixed;
+    border-collapse: collapse;
+}
+
+.copy th,
+.copy td {
+    padding: 4px 0;
+    text-transform: uppercase;
+    font-size: 10px;
+}
+
+.copy td {
+    border-bottom: 1px solid var(--c-forest-green);
+    text-align: center;
+}
+
+.copy td:first-child {
+    text-align: left;
+    color: var(--c-light-green);
+}
+
+
+/* grid border ref: <https://geary.co/internal-borders-css-grid/> */
+.related-products {
+    grid-template-columns: repeat(2, 1fr);
+    overflow: hidden;
+    --gap: 4px;
+    --line-offset: calc(var(--gap) / 2);
+    --line-thickness: 1px;
+    --line-color: var(--c-gray);
+}
+
+@media screen and (min-width: 760px) {
+    .related-products {
+	grid-template-columns: repeat(4, 1fr);
+    }
+}
+
+@media screen and (min-width: 2400px) {
+    .related-products {
+	grid-template-columns: repeat(5, 1fr);
+    }
+}
+
+/* add border magic-filler to the last row not having enough blocks to
+create an even border-bottom line */
+.related-products::after {
+    content: '';
+    background-color: var(--c-gray);
+    width: 1000%;
+    height: 1px;
+    margin-top: auto;
+}
+
+.related-products-child {
+    border-bottom: 1px solid var(--c-gray);
+    border-right: 1px solid var(--c-gray);
+}
+
+.related-products a:hover {
+    color: var(--c-cyan);
+    border-right: 1px solid var(--c-gray);
+}
+
+.cart-icon:hover{
+    color: var(--c-cyan);
+}
+
+.fgc-cyan:hover{
+    color: var(--c-pink);
+}
+
+.copy-checkout-disclaimer {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    gap: 20px;
+}
+
+@media screen and (min-width: 760px) {
+    .copy-checkout-disclaimer {
+	gap: 34px;
+    }
+}
+
+.sunset {
+    width: 100%;
+    height: 15vh;
+    background-repeat: no-repeat;
+    background-position: bottom;
+}
+
+/* --- */
+
+.curp {
+    cursor: pointer;
+}
+
+.cura {
+    cursor: auto;
+}
+
+/* sizes */
+
+.w5r {
+    width: 5rem;
+}
+
+.w30 {
+    width: 30%;
+}
+
+.w50 {
+    width: 50%;
+}
+
+.w70 {
+    width: 70%;
+}
+
+.w100 {
+    width: 100%;
+}
+
+.mw-680px {
+    max-width: 680px;
+}
+
+.h15vh {
+    height: 15vh;
+}
+
+.h100 {
+    height: 100%;
+}
+
+@media screen and (min-width: 540px) {
+    .sm-w30 {
+	width: 30%;
+    }
+
+    .sm-w70 {
+	width: 70%;
+    }
+
+    .sm-w100 {
+	width: 100%;
+    }
+}
+
+@media screen and (min-width: 760px) {
+    .md-w25 {
+	width: 25%;
+    }
+
+    .md-w30 {
+	width: 30%;
+    }
+
+    .md-w50 {
+	width: 50%;
+    }
+
+    .md-w70 {
+	width: 70%;
+    }
+
+    .md-w100 {
+	width: 100%;
+    }
+
+    .md-h100 {
+	height: 100%;
+    }
+}
+
+@media screen and (min-width: 1280px) {
+    .bg-w25 {
+	width: 25%;
+    }
+
+    .bg-w50 {
+	width: 50%;
+    }
+
+    .bg-w75 {
+	width: 75%;
+    }
+
+    .bg-w100 {
+	width: 100%;
+    }
+}
+
+@media screen and (min-width: 1920px) {
+    .lg-w25 {
+	width: 25%;
+    }
+
+    .lg-w75 {
+	width: 75%;
+    }
+
+    .lg-w50 {
+	width: 50%;
+    }
+}
+
+@media screen and (min-width: 2400px) {
+    .hg-w1200 {
+	width: 1200px;
+    }
+
+    .hg-x1 {
+	flex: 1;
+    }
+}
+
+.nav-height {
+    padding-top: 34.7px
+}
+
+@media screen and (min-width: 760px) {
+    .nav-height {
+	padding-top: 46.7px
+    }
+}
+
+.form-input {
+    padding: 14.5px 10px;
+    background-color: #00120e;
+    border: 1px solid #213f38;
+}
+
+@media screen and (min-width: 760px) {
+    .sticky-prod-info {
+	position: sticky;
+	top: 48px;
+	background-color: var(--c-black);
+	z-index: 1;
+    }
+}
+
+/* icons */
+
+.icon-p {
+    width: 12px;
+    height: 12px;
+}

+ 187 - 0
static/dist/checkout-7PTSF3BQ.js

@@ -0,0 +1,187 @@
+// static/js/cart.js
+async function updateCart(form) {
+  const formData = new FormData(form);
+  const res = await fetch("/checkout?js=true", {
+    method: "POST",
+    body: formData
+  });
+  if (res.ok) {
+    return res.json();
+  } else {
+    return { status: res.status, error: res.statusText };
+  }
+}
+function updateCartIcon(data) {
+  const cart_icon = document.querySelector(".cart-icon");
+  if (cart_icon.length !== null) {
+    let cart_icon_amount_list = cart_icon.querySelector(".cart-icon--amount");
+    if (cart_icon_amount_list.length !== null) {
+      cart_icon_amount_list.innerText = data.cart_breakdown.item_amount;
+    }
+    cart_icon.dataset.status = "busy";
+    cart_icon.style.color = "#FF7070";
+    cart_icon.style.textDecorationColor = "#FF7070";
+    setTimeout(() => {
+      cart_icon.dataset.status = "free";
+      cart_icon.style.color = "#fff";
+      cart_icon.style.textDecorationColor = "#fff";
+    }, "2000");
+  }
+}
+function updateItemRow(item_row, cart_items, item_amount) {
+  let item = cart_items.find((item2) => item2.product_id == item_row.id);
+  if (item) {
+    item_row.querySelector("#quantity_manual").value = item.quantity;
+    let buttonPlus = item_row.querySelector("#button-plus");
+    if (!item.is_selectable) {
+      buttonPlus.setAttribute("disabled", "disabled");
+      buttonPlus.classList.add("cura");
+    } else {
+      buttonPlus.removeAttribute("disabled");
+      buttonPlus.classList.remove("cura");
+    }
+    let total = item_row.querySelector(".item-total");
+    total.querySelector(".item-total--text").textContent = "".concat(item.total);
+    total.querySelector("#price").value = item.total;
+  } else {
+    item_row.remove();
+  }
+}
+function updateBreakdown(cart_breakdown) {
+  let subtotal_amount = Array.from(document.querySelectorAll(".subtotal--amount > .amount"));
+  subtotal_amount.forEach((subtotal) => {
+    subtotal.textContent = "".concat(cart_breakdown.subtotal);
+  });
+  let shipping = document.querySelectorAll(".subtotal--shipping > .amount");
+  shipping.forEach((shipping2) => {
+    shipping2.textContent = "".concat(cart_breakdown.shipping.toFixed(1));
+  });
+  let subtotal_total = document.querySelector(".subtotal--total > .amount");
+  if (subtotal_total) {
+    subtotal_total.textContent = "".concat(cart_breakdown.total.toFixed(1));
+  }
+  ;
+}
+function removeCheckoutTable() {
+  document.querySelector(".checkout-table-header").remove();
+  document.querySelector(".checkout-shipping-info").remove();
+  document.querySelector(".checkout-empty-message").classList.remove("dn");
+}
+function updateTable(cart_items, cart_item_rows, cart_breakdown) {
+  cart_item_rows.map((item_row) => {
+    updateItemRow(item_row, cart_items, cart_breakdown["item_amount"]);
+  });
+  if (cart_items.length > 0) {
+    updateBreakdown(cart_breakdown);
+  } else {
+    removeCheckoutTable();
+  }
+}
+function displayProductSelectable(btnSubmit, isProductSelectable, isProductAvailable) {
+  if (!btnSubmit) {
+    return;
+  }
+  btnSubmit.classList.remove(
+    "btn-product-available",
+    "btn-product-available",
+    "btn-product-unselectable"
+  );
+  if (!isProductAvailable) {
+    btnSubmit.setAttribute("disabled", "disabled");
+    btnSubmit.classList.add("btn-product-soldout");
+    btnSubmit.value = "sold out";
+  } else if (!isProductSelectable) {
+    btnSubmit.setAttribute("disabled", "disabled");
+    btnSubmit.classList.add("btn-product-unselectable");
+    btnSubmit.value = "add to cart";
+  } else {
+    btnSubmit.removeAttribute("disabled");
+    btnSubmit.classList.add("btn-product-available");
+    btnSubmit.value = "add to cart";
+  }
+}
+
+// static/js/checkout.js
+async function processForm(form) {
+  form.addEventListener("submit", async (e) => {
+    e.preventDefault();
+    const data = await updateCart(form);
+    updateCartIcon(data);
+    const cart_items = data.cart_items;
+    const cart_breakdown = data.cart_breakdown;
+    const cart_item_rows = Array.from(document.querySelectorAll(".cart-table-row"));
+    updateTable(cart_items, cart_item_rows, cart_breakdown);
+    if ("is_product_available" in data) {
+      const isProductSelectable = data.is_product_selectable;
+      const isProductAvailable = data.is_product_available;
+      const btnSubmit = document.querySelector(".btn-submit");
+      displayProductSelectable(btnSubmit, isProductSelectable, isProductAvailable);
+    }
+    ;
+  });
+}
+async function updateShippingCost(country, weight, csrftoken) {
+  const formData = new FormData();
+  formData.append("country", country);
+  formData.append("weight", weight);
+  formData.append("csrftoken", csrftoken);
+  const res = await fetch("/checkout/shipping?js=true", {
+    method: "POST",
+    body: formData
+  });
+  if (res.ok) {
+    return res.json();
+  } else {
+    return { status: res.status, error: res.statusText };
+  }
+}
+function prepareShippingCost(selectCountry) {
+  let weight = document.querySelector('.checkout-shipping-info > input[name="weight"]');
+  if (weight !== void 0) {
+    weight = Number(weight.value);
+  }
+  let csrftoken = "";
+  const token = document.querySelector('.checkout-shipping-info > input[name="csrftoken"]');
+  if (token !== void 0) {
+    csrftoken = token.value;
+  }
+  let country = "";
+  if (selectCountry.value !== "") {
+    country = selectCountry.value;
+  }
+  if (!isNaN(weight) && csrftoken !== "" && country !== "") {
+    return {
+      weight,
+      csrftoken,
+      country
+    };
+  }
+}
+async function setShippingCost() {
+  const selectCountry = document.querySelector("select#country");
+  if (selectCountry !== void 0 && selectCountry !== null) {
+    const data = prepareShippingCost(selectCountry);
+    if (data !== void 0) {
+      const cart_breakdown = await updateShippingCost(data.country, data.weight, data.csrftoken);
+      updateBreakdown(cart_breakdown);
+    }
+    ;
+    selectCountry.addEventListener("change", async (e) => {
+      const data2 = prepareShippingCost(selectCountry);
+      if (data2 !== void 0) {
+        const cart_breakdown = await updateShippingCost(data2.country, data2.weight, data2.csrftoken);
+        updateBreakdown(cart_breakdown);
+      }
+      ;
+    });
+  }
+  ;
+}
+window.addEventListener("load", () => {
+  ;
+  (async () => {
+    const forms = Array.from(document.querySelectorAll(".checkout-item"));
+    await Promise.all(forms.map(async (form) => processForm(form)));
+    await setShippingCost();
+  })();
+});

binární
static/dist/fonts/IBMPlexMono-Light.woff


binární
static/dist/fonts/IBMPlexMono-Light.woff2


binární
static/dist/fonts/IBMPlexMono-LightItalic.woff


binární
static/dist/fonts/IBMPlexMono-LightItalic.woff2


binární
static/dist/fonts/Inter-Bold.woff


binární
static/dist/fonts/Inter-Bold.woff2


binární
static/dist/fonts/Inter-BoldItalic.woff


binární
static/dist/fonts/Inter-BoldItalic.woff2


binární
static/dist/fonts/weissrundgotisch.regular.woff


binární
static/dist/fonts/weissrundgotisch.regular.woff2


+ 116 - 0
static/dist/product-R6DMP5AX.js

@@ -0,0 +1,116 @@
+// static/js/cart.js
+async function updateCart(form) {
+  const formData = new FormData(form);
+  const res = await fetch("/checkout?js=true", {
+    method: "POST",
+    body: formData
+  });
+  if (res.ok) {
+    return res.json();
+  } else {
+    return { status: res.status, error: res.statusText };
+  }
+}
+function updateCartIcon(data) {
+  const cart_icon = document.querySelector(".cart-icon");
+  if (cart_icon.length !== null) {
+    let cart_icon_amount_list = cart_icon.querySelector(".cart-icon--amount");
+    if (cart_icon_amount_list.length !== null) {
+      cart_icon_amount_list.innerText = data.cart_breakdown.item_amount;
+    }
+    cart_icon.dataset.status = "busy";
+    cart_icon.style.color = "#FF7070";
+    cart_icon.style.textDecorationColor = "#FF7070";
+    setTimeout(() => {
+      cart_icon.dataset.status = "free";
+      cart_icon.style.color = "#fff";
+      cart_icon.style.textDecorationColor = "#fff";
+    }, "2000");
+  }
+}
+function displayProductSelectable(btnSubmit, isProductSelectable, isProductAvailable) {
+  if (!btnSubmit) {
+    return;
+  }
+  btnSubmit.classList.remove(
+    "btn-product-available",
+    "btn-product-available",
+    "btn-product-unselectable"
+  );
+  if (!isProductAvailable) {
+    btnSubmit.setAttribute("disabled", "disabled");
+    btnSubmit.classList.add("btn-product-soldout");
+    btnSubmit.value = "sold out";
+  } else if (!isProductSelectable) {
+    btnSubmit.setAttribute("disabled", "disabled");
+    btnSubmit.classList.add("btn-product-unselectable");
+    btnSubmit.value = "add to cart";
+  } else {
+    btnSubmit.removeAttribute("disabled");
+    btnSubmit.classList.add("btn-product-available");
+    btnSubmit.value = "add to cart";
+  }
+}
+function updateProductURL(product_selected) {
+  if ("URLSearchParams" in window) {
+    const searchParams = new URLSearchParams(product_selected);
+    history.pushState(null, "", "?".concat(searchParams.toString()));
+  }
+}
+
+// static/js/product.js
+async function processForm(form) {
+  form.addEventListener("submit", async (e) => {
+    e.preventDefault();
+    const data = await updateCart(form);
+    updateCartIcon(data);
+    const btnSubmit = document.querySelector(".btn-submit");
+    const isProductSelectable = data.is_product_selectable;
+    const isProductAvailable = data.is_product_available;
+    displayProductSelectable(btnSubmit, isProductSelectable, isProductAvailable);
+    updateProductURL(data.product_selected);
+  });
+}
+async function checkProductAvailability(product_path, searchParams) {
+  const res = await fetch("/".concat(product_path, "?js=true&").concat(searchParams.toString()));
+  if (res.ok) {
+    return res.json();
+  } else {
+    return { status: res.status, error: res.statusText };
+  }
+}
+async function changeProductURLOnInputRadio(form) {
+  const inputs = Array.from(form.querySelectorAll(".input-listen"));
+  await Promise.all(inputs.map(async (input) => {
+    input.addEventListener("change", async (e) => {
+      let key = e.target.name;
+      let value = e.target.value;
+      if ("URLSearchParams" in window) {
+        const product_path = form.querySelector("#product_path").value;
+        const searchParams = new URLSearchParams(window.location.search);
+        searchParams.set(key, value);
+        let size = searchParams.get("size");
+        let style = searchParams.get("style");
+        if (size && style) {
+          const data = await checkProductAvailability(product_path, searchParams);
+          const btnSubmit = document.querySelector(".btn-submit");
+          const isProductSelectable = data.is_product_selectable;
+          const isProductAvailable = data.is_product_available;
+          displayProductSelectable(btnSubmit, isProductSelectable, isProductAvailable);
+        }
+        history.pushState(null, "", "?".concat(searchParams.toString()));
+      }
+      ;
+    });
+  }));
+}
+window.addEventListener("load", () => {
+  ;
+  (async () => {
+    const forms = Array.from(document.querySelectorAll(".product-item"));
+    await Promise.all(forms.map(async (form) => {
+      processForm(form);
+      changeProductURLOnInputRadio(form);
+    }));
+  })();
+});

+ 1208 - 0
static/dist/styles-3XFNZ3JU.css

@@ -0,0 +1,1208 @@
+/* static/css/styles.css */
+@font-face {
+  font-family: "IBM Plex Mono Light";
+  src:
+    url("./fonts/IBMPlexMono-Light.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-Light.woff") format("woff"),
+    font-style: normal;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "IBM Plex Mono Light Italic";
+  src:
+    url("./fonts/IBMPlexMono-LightItalic.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-LightItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold";
+  src:
+    url("./fonts/Inter-Bold.woff2") format("woff2"),
+    url("./fonts/Inter-Bold.woff") format("woff"),
+    font-style: normal;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold Italic";
+  src:
+    url("./fonts/Inter-BoldItalic.woff2") format("woff2"),
+    url("./fonts/Inter-BoldItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Weiss Rund Gotisch";
+  src:
+    url("./fonts/weissrundgotisch.regular.woff2") format("woff2"),
+    url("./fonts/weissrundgotisch.regular.woff") format("woff"),
+    font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+}
+:root {
+  --nav-height: 35px;
+  --c-white: #ffffff;
+  --c-anon-gray: #a5a5a5;
+  --c-light-gray: #dbdbdb;
+  --c-gray: #b7b7b7;
+  --c-pink: #ffbcfc;
+  --c-dark-bright-brown: #241b18;
+  --c-dark-rubin: #3d230b;
+  --c-rubin: #610000;
+  --c-dark-red: #520000;
+  --c-deep-brown: #2B2222;
+  --c-terre: #AA9494;
+  --c-green: #8dff98;
+  --c-forest-green: #4c8042;
+  --c-light-green: #f2ffcd;
+  --c-light-blue: #bffbff;
+  --c-another-blue: #80d1ff;
+  --c-cyan: #00ffff;
+  --c-bluegreen: #002f10;
+  --c-dark-green: #002C29;
+  --c-dark-bluegreen: #001b1d;
+  --c-blue: #00114c;
+  --c-dark-blue: #00111d;
+  --c-brown: #2f2a00;
+  --c-dark-brown: #1a1d00;
+  --c-gray: #4b4b4b;
+  --c-dark-gray: #555353;
+  --c-bright-black: #020713;
+  --c-gaow: #160E0E;
+  --c-blackish: #11181d;
+  --c-dark-black: #090d10;
+  --c-black: #090909;
+  --c-blackest: #000000;
+  --c-yet-gray: #292929;
+  --f-mono: "IBM Plex Mono Light", monospace;
+  --f-mono-italic: "IBM Plex Mono Light Italic", monospace;
+  --f-sans: "Inter Bold", sans-serif;
+  --f-sans-italic: "Inter Bold Italic", sans-serif;
+  --f-gothic: "Weiss Rund Gotisch", serif;
+}
+@media screen and (min-width: 760px) {
+  :root {
+    --nav-height: 47px;
+  }
+}
+html {
+  box-sizing: border-box;
+  scroll-padding-top: var(--nav-height);
+}
+*,
+*:before,
+*:after {
+  box-sizing: inherit;
+}
+html,
+body {
+  scroll-behavior: smooth;
+}
+html,
+body,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+ol,
+ul,
+li,
+figure {
+  margin: 0;
+  padding: 0;
+}
+figure img {
+  display: block;
+}
+ol,
+ul {
+  padding-left: 1rem;
+}
+img {
+  max-width: 100%;
+  height: auto;
+}
+button {
+  font-family: inherit;
+  font-size: inherit;
+  border: none;
+  border-radius: 0;
+  margin: 0;
+  padding: 0;
+  background: none;
+  color: inherit;
+  cursor: pointer;
+}
+body {
+  -webkit-overflow-scrolling: touch;
+  -ms-overflow-style: none;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  text-rendering: optimizeLegibility;
+  width: 100%;
+  height: 100%;
+}
+html {
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  color: var(--c-white);
+  font-size: 12px;
+  font-family: var(--f-mono);
+}
+a {
+  text-decoration: none;
+  color: inherit;
+}
+input,
+select,
+textarea {
+  background-color: inherit;
+  color: inherit;
+  border: 0;
+  font-family: inherit;
+  font-style: inherit;
+  font-weight: inherit;
+  font-size: inherit;
+  margin: 0;
+  padding: 0;
+}
+input::placeholder,
+textarea::placeholder {
+  opacity: 1;
+  color: #9f879b;
+}
+fieldset {
+  border: 0;
+  padding: 0;
+}
+.fs-small {
+  font-size: 10px;
+}
+.fs-medium {
+  font-size: 1rem;
+}
+.fs-biggish {
+  font-size: 14px;
+}
+.fs-big {
+  font-size: 15px;
+}
+.fs-bigger {
+  font-size: 16px;
+}
+.fs-large {
+  font-size: 18px;
+}
+.fs-larger {
+  font-size: 20px;
+}
+.fs-huge {
+  font-size: 24px;
+}
+.fs-wide {
+  font-size: 30px;
+}
+.ff-sans {
+  font-family: var(--f-sans);
+}
+.ff-sans em,
+.ff-sans italic {
+  font-family: var(--f-sans-italic);
+  font-style: normal;
+}
+.ff-mono em,
+.ff-mono italic {
+  font-family: var(--f-mono-italic);
+  font-style: normal;
+}
+@media screen and (min-width: 540px) {
+  .sm-fs-medium {
+    font-size: 1rem;
+  }
+  .sm-fs-biggish {
+    font-size: 14px;
+  }
+  .sm-fs-big {
+    font-size: 15px;
+  }
+  .sm-fs-large {
+    font-size: 18px;
+  }
+  .sm-fs-larger {
+    font-size: 20px;
+  }
+  .sm-fs-huge {
+    font-size: 24px;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-fs-big {
+    font-size: 15px;
+  }
+}
+.tac {
+  text-align: center;
+}
+.tar {
+  text-align: right;
+}
+@media screen and (min-width: 540px) {
+  .sm-tar {
+    text-align: right;
+  }
+}
+.copy h3,
+.copy h4,
+.copy h5,
+.copy h6,
+.copy p,
+.copy table {
+  max-width: 700px;
+}
+.copy-h-reset-weight h1,
+.copy-h-reset-weight h2,
+.copy-h-reset-weight h3,
+.copy-h-reset-weight h4,
+.copy-h-reset-weight h5,
+.copy-h-reset-weight h6 {
+  font-weight: inherit;
+}
+.copy-h-reset h1,
+.copy-h-reset h2,
+.copy-h-reset h3,
+.copy-h-reset h4,
+.copy-h-reset h5,
+.copy-h-reset h6 {
+  font-size: inherit;
+  font-style: inherit;
+}
+.copy-h h1,
+.copy-h h2 {
+  font-size: 30px;
+  font-family: var(--f-gothic);
+  padding: 15px 20px;
+  border-bottom: 1px solid var(--c-gray);
+}
+@media screen and (min-width: 1920px) {
+  .copy-h h1,
+  .copy-h h2 {
+    padding: 30px 50px;
+  }
+  .copy-h-front h1,
+  .copy-h-front h2,
+  .copy-h-page h1,
+  .copy-h-page h2 {
+    padding: 2.5rem 4.2rem;
+  }
+}
+.copy-h-page h3,
+.copy-h-page h4,
+.copy-h-page h5,
+.copy-h-page h6 {
+  font-size: inherit;
+}
+.copy-page h3,
+.copy-page h4,
+.copy-page h5,
+.copy-page h6,
+.copy-page p {
+  padding: 0.75rem 0.75rem 2.5rem 0.7rem;
+}
+.copy-page table {
+  margin: 0 0.75rem 2.45em 0.7rem;
+}
+.copy-page table td {
+  width: 7.5rem;
+}
+.copy a {
+  color: var(--c-cyan);
+}
+.ttu {
+  text-transform: uppercase;
+}
+.hvc:hover {
+  color: var(--c-green);
+}
+.checkout-image {
+  width: 120px;
+}
+@media screen and (min-width: 540px) {
+  .checkout-image {
+    width: 220px;
+  }
+}
+.img-page img {
+  max-height: 1000px;
+}
+@media screen and (min-width: 2400px) {
+  .img-page img {
+    max-height: 12000px;
+  }
+}
+.bd-white {
+  border: 1px solid var(--c-white);
+}
+.bdb-pink {
+  border-bottom: 1px solid var(--c-pink);
+}
+.bdb-dark-green {
+  border-bottom: 1px solid var(--c-dark-green);
+}
+.bd-dark-rubin {
+  border: 1px solid var(--c-dark-rubin);
+}
+.bdt-dark-red {
+  border-top: 1px solid var(--c-dark-red);
+}
+.bdb-dark-red {
+  border-bottom: 1px solid var(--c-dark-red);
+}
+.bd-yet-gray {
+  border: 1px solid var(--c-yet-gray);
+}
+.bd-deep-brown {
+  border: 1px solid var(--c-deep-brown);
+}
+.bdt-gray {
+  border-top: 1px solid var(--c-gray);
+}
+.bdb-gray {
+  border-bottom: 1px solid var(--c-gray);
+}
+.bd-dark-gray {
+  border: 1px solid var(--c-dark-gray);
+}
+.bd-brown {
+  border: 1px solid var(--c-brown);
+}
+.bd-blue {
+  border: 1px solid var(--c-blue);
+}
+.bd-bluegreen {
+  border: 1px solid var(--c-bluegreen);
+}
+@media screen and (min-width: 760px) {
+  .md-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+  .md-bdl-rubin {
+    border-left: 1px solid var(--c-rubin);
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+  .bg-bdl-gray {
+    border-left: 1px solid var(--c-gray);
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+  .lg-bdl-gray {
+    border-left: 1px solid var(--c-gray);
+  }
+  .lg-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+}
+.brad-6 {
+  border-radius: 6px;
+}
+.vh {
+  clip: rect(0 0 0 0);
+  -webkit-clip-path: inset(50%);
+  clip-path: inset(50%);
+  height: 1px;
+  overflow: hidden;
+  position: absolute;
+  white-space: nowrap;
+  width: 1px;
+}
+.dn {
+  display: none;
+}
+.dib {
+  display: inline-block;
+}
+.db {
+  display: block;
+}
+.x {
+  display: flex;
+}
+.xw {
+  flex-wrap: wrap;
+}
+.xdr {
+  flex-direction: row;
+}
+.xdc {
+  flex-direction: column;
+}
+.xaic {
+  align-items: center;
+}
+.xaib {
+  align-items: baseline;
+}
+.xjsb {
+  justify-content: space-between;
+}
+.xo1 {
+  order: 1;
+}
+.xo2 {
+  order: 2;
+}
+.xo3 {
+  order: 3;
+}
+.x1 {
+  flex: 1;
+}
+.g-4-16 {
+  gap: 4.16rem;
+}
+@media screen and (min-width: 400px) {
+  .tn-xdr {
+    flex-direction: row;
+  }
+}
+@media screen and (min-width: 540px) {
+  .sm-xaic {
+    align-items: center;
+  }
+  .sm-xo1 {
+    order: 1;
+  }
+  .sm-xo2 {
+    order: 2;
+  }
+  .sm-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-dn {
+    display: none;
+  }
+  .md-db {
+    display: block;
+  }
+  .md-x {
+    display: flex;
+  }
+  .md-xdc {
+    flex-direction: column;
+  }
+  .md-xdr {
+    flex-direction: row;
+  }
+  .md-xw {
+    flex-wrap: wrap;
+  }
+  .md-xo1 {
+    order: 1;
+  }
+  .md-xo2 {
+    order: 2;
+  }
+  .md-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-xw {
+    flex-wrap: wrap;
+  }
+  .bg-xdr {
+    flex-direction: row;
+  }
+  .bg-xdc {
+    flex-direction: column;
+  }
+  .bg-xjsb {
+    justify-content: space-between;
+  }
+  .bg-xo1 {
+    order: 1;
+  }
+  .bg-xo2 {
+    order: 2;
+  }
+  .bg-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-x {
+    display: flex;
+  }
+  .lg-xdr {
+    flex-direction: row;
+  }
+  .lg-xw {
+    flex-wrap: wrap;
+  }
+  .lg-xdc {
+    flex-direction: column;
+  }
+  .lg-xo1 {
+    order: 1;
+  }
+  .lg-xo2 {
+    order: 2;
+  }
+  .lg-xo3 {
+    order: 3;
+  }
+  .lg-xjsb {
+    justify-content: space-between;
+  }
+}
+.dg {
+  display: grid;
+}
+.z1 {
+  z-index: 1;
+}
+.z2 {
+  z-index: 2;
+}
+.psr {
+  position: relative;
+}
+.psf {
+  position: fixed;
+}
+.psa {
+  position: absolute;
+}
+.t0 {
+  top: 0;
+}
+.b0 {
+  bottom: 0;
+}
+.l0 {
+  left: 0;
+}
+.t50 {
+  top: 50%;
+}
+.l50 {
+  left: 50%;
+}
+.ttcx {
+  transform: translateX(-50%);
+}
+.ttcc {
+  transform: translate(-50%, -50%);
+}
+.mta {
+  margin-top: auto;
+}
+.mla {
+  margin-left: auto;
+}
+.mra {
+  margin-right: auto;
+}
+.mt1 {
+  margin-top: 1rem;
+}
+.mr1 {
+  margin-right: 1rem;
+}
+.mr0-5 {
+  margin-right: 0.5rem;
+}
+.ml0-5 {
+  margin-left: 0.5rem;
+}
+.ml1 {
+  margin-left: 1rem;
+}
+.mb1-25 {
+  margin-bottom: 1.25rem;
+}
+.mb4-25 {
+  margin-bottom: 4.25rem;
+}
+.pt0-25 {
+  padding-top: 0.25rem;
+}
+.pr0-25 {
+  padding-right: 0.25rem;
+}
+.pb0-25 {
+  padding-bottom: 0.25rem;
+}
+.pl0-25 {
+  padding-left: 0.25rem;
+}
+.pt0-5 {
+  padding-top: 0.5rem;
+}
+.pr0-5 {
+  padding-right: 0.5rem;
+}
+.pb0-5 {
+  padding-bottom: 0.5rem;
+}
+.pl0-5 {
+  padding-left: 0.5rem;
+}
+.pt0-75 {
+  padding-top: 0.75rem;
+}
+.pr0-75 {
+  padding-right: 0.75rem;
+}
+.pb0-75 {
+  padding-bottom: 0.75rem;
+}
+.pl0-75 {
+  padding-left: 0.75rem;
+}
+.pt0-85 {
+  padding-top: 0.85rem;
+}
+.pr0-85 {
+  padding-right: 0.85rem;
+}
+.pb0-85 {
+  padding-bottom: 0.85rem;
+}
+.pl0-85 {
+  padding-left: 0.85rem;
+}
+.pt1 {
+  padding-top: 1rem;
+}
+.pb1 {
+  padding-bottom: 1rem;
+}
+.pb1-25 {
+  padding-bottom: 1.25rem;
+}
+.pt1-7 {
+  padding-top: 1.7rem;
+}
+.pb1-7 {
+  padding-bottom: 1.7rem;
+}
+.pb2 {
+  padding-bottom: 2rem;
+}
+.pt3-5 {
+  padding-top: 3.5rem;
+}
+.pb3 {
+  padding-bottom: 3rem;
+}
+.pt4-25 {
+  padding-top: 4.25rem;
+}
+.pb4-25 {
+  padding-bottom: 4.25rem;
+}
+.pt4-75 {
+  padding-top: 4.75rem;
+}
+.pb4-75 {
+  padding-bottom: 4.75rem;
+}
+.pb5-5 {
+  padding-bottom: 5.5rem;
+}
+.pt7 {
+  padding-top: 7rem;
+}
+.pb7 {
+  padding-bottom: 7rem;
+}
+@media screen and (min-width: 540px) {
+  .sm-pr0 {
+    padding-right: 0;
+  }
+  .sm-pl0 {
+    padding-left: 0;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-ml1-25 {
+    margin-left: 1.25rem;
+  }
+  .md-pt0-5 {
+    padding-top: 0.5rem;
+  }
+  .md-pr0-5 {
+    padding-right: 0.5rem;
+  }
+  .md-pb0-5 {
+    padding-bottom: 0.5rem;
+  }
+  .md-pl0-5 {
+    padding-left: 0.5rem;
+  }
+  .md-pt1 {
+    padding-top: 1rem;
+  }
+  .md-pr1 {
+    padding-right: 1rem;
+  }
+  .md-pb1 {
+    padding-bottom: 1rem;
+  }
+  .md-pl1 {
+    padding-left: 1rem;
+  }
+  .md-pr1-3 {
+    padding-right: 1.3rem;
+  }
+  .md-pl1-3 {
+    padding-left: 1.3rem;
+  }
+  .md-pt3-35 {
+    padding-top: 3.35rem;
+  }
+  .md-pr3-35 {
+    padding-right: 3.35rem;
+  }
+  .md-pb3-35 {
+    padding-bottom: 3.35rem;
+  }
+  .md-pl3-35 {
+    padding-left: 3.35rem;
+  }
+  .md-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .md-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .md-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .md-pl4-2 {
+    padding-left: 4.2rem;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-pb0 {
+    padding-bottom: 0;
+  }
+  .bg-pt1-25 {
+    padding-top: 1.25rem;
+  }
+  .bg-pr1-25 {
+    padding-right: 1.25rem;
+  }
+  .bg-pb1-25 {
+    padding-bottom: 1.25rem;
+  }
+  .bg-pl1-25 {
+    padding-left: 1.25rem;
+  }
+  .bg-pt1-7 {
+    padding-top: 1.7rem;
+  }
+  .bg-pr1-7 {
+    padding-right: 1.7rem;
+  }
+  .bg-pb1-7 {
+    padding-bottom: 1.7rem;
+  }
+  .bg-pl1-7 {
+    padding-left: 1.7rem;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .bg-pr8-3 {
+    padding-right: 8.3rem;
+  }
+  .bg-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .bg-pl4-2 {
+    padding-left: 4.2rem;
+  }
+  .bg-pl8-3 {
+    padding-left: 8.3rem;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-pb0 {
+    padding-bottom: 0;
+  }
+  .lg-pr2 {
+    padding-right: 2;
+  }
+  .lg-pt0-42 {
+    padding-top: 0.42rem;
+  }
+  .lg-pr0-42 {
+    padding-right: 0.42rem;
+  }
+  .lg-pb0-42 {
+    padding-bottom: 0.42rem;
+  }
+  .lg-pl0-42 {
+    padding-left: 0.42rem;
+  }
+  .lg-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .lg-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .lg-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .lg-pl4-2 {
+    padding-left: 4.2rem;
+  }
+  .lg-pt8-3 {
+    padding-top: 8.3rem;
+  }
+  .lg-pr8-3 {
+    padding-right: 8.3rem;
+  }
+  .lg-pb8-3 {
+    padding-bottom: 8.3rem;
+  }
+  .lg-pl8-3 {
+    padding-left: 8.3rem;
+  }
+}
+.logo-eye {
+  width: 27px;
+  height: 22.7px;
+}
+.logo-hardware {
+  width: 120px;
+  height: 14px;
+}
+@media screen and (min-width: 760px) {
+  .logo-hardware {
+    width: 160px;
+    height: 18px;
+  }
+}
+.bgc-white {
+  background-color: var(--c-white);
+}
+.bgc-gr-black {
+  background:
+    linear-gradient(
+      180deg,
+      var(--c-blackish) 0%,
+      var(--c-dark-black) 100%);
+  background: var(--c-dark-black);
+}
+.bgc-black {
+  background-color: var(--c-black);
+}
+.bgc-blackest {
+  background-color: var(--c-blackest);
+}
+.bgc-gaow {
+  background-color: var(--c-gaow);
+}
+.bgc-bright-black {
+  background-color: var(--c-bright-black);
+}
+.fgc-terre {
+  color: var(--c-terre);
+}
+.fgc-gray {
+  color: var(--c-gray);
+}
+.fgc-dark-gray {
+  color: var(--c-dark-gray);
+}
+.bgc-dark-brown {
+  background-color: var(--c-dark-brown);
+}
+.bgc-dark-bright-brown {
+  background-color: var(--c-dark-bright-brown);
+}
+.fgc-light-gray {
+  color: var(--c-light-gray);
+}
+.fgc-anon-gray {
+  color: var(--c-anon-gray);
+}
+.fgc-green {
+  color: var(--c-green);
+}
+.fgc-light-blue {
+  color: var(--c-light-blue);
+}
+.bgc-dark-blue {
+  background-color: var(--c-dark-blue);
+}
+.bgc-dark-bluegreen {
+  background-color: var(--c-dark-bluegreen);
+}
+.fgc-cyan {
+  color: var(--c-cyan);
+}
+.btn-product-code {
+  padding: 2px 4px 3px 4px;
+}
+.btn-checkout-remove {
+  padding: 5px 7px;
+}
+.btn-submit {
+  padding: 8px 12px 10px 12px;
+}
+.btn-product-soldout {
+  border: 1px solid var(--c-dark-rubin);
+  background-color: var(--c-dark-bright-brown);
+  color: var(--c-light-gray);
+}
+.btn-product-unselectable {
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-black);
+  color: var(--c-light-gray);
+}
+.btn-product-available {
+  cursor: pointer;
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-bluegreen);
+  color: var(--c-cyan);
+}
+.payment-buttons {
+  gap: 1rem;
+}
+.btn-stripe {
+  width: 88px;
+  height: 42px;
+}
+.btn-nowpayments {
+  width: 170px;
+  height: 42px;
+}
+@media screen and (min-width: 540px) {
+  .btn-stripe {
+    width: 116px;
+    height: 55px;
+  }
+  .btn-nowpayments {
+    width: 220px;
+    height: 55px;
+  }
+}
+.product-size-guide h1,
+.product-size-guide h2,
+.product-size-guide h3,
+.product-size-guide h4,
+.product-size-guide h5,
+.product-size-guide h6 {
+  color: var(--c-another-blue);
+  text-transform: uppercase;
+  padding-bottom: 1.7rem;
+}
+.product-size-guide table {
+  width: 100%;
+}
+.copy table {
+  table-layout: fixed;
+  border-collapse: collapse;
+}
+.copy th,
+.copy td {
+  padding: 4px 0;
+  text-transform: uppercase;
+  font-size: 10px;
+}
+.copy td {
+  border-bottom: 1px solid var(--c-forest-green);
+  text-align: center;
+}
+.copy td:first-child {
+  text-align: left;
+  color: var(--c-light-green);
+}
+.related-products {
+  grid-template-columns: repeat(2, 1fr);
+  overflow: hidden;
+  --gap: 4px;
+  --line-offset: calc(var(--gap) / 2);
+  --line-thickness: 1px;
+  --line-color: var(--c-gray);
+}
+@media screen and (min-width: 760px) {
+  .related-products {
+    grid-template-columns: repeat(4, 1fr);
+  }
+}
+@media screen and (min-width: 2400px) {
+  .related-products {
+    grid-template-columns: repeat(5, 1fr);
+  }
+}
+.related-products::after {
+  content: "";
+  background-color: var(--c-gray);
+  width: 1000%;
+  height: 1px;
+  margin-top: auto;
+}
+.related-products-child {
+  border-bottom: 1px solid var(--c-gray);
+  border-right: 1px solid var(--c-gray);
+}
+.copy-checkout-disclaimer {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 20px;
+}
+@media screen and (min-width: 760px) {
+  .copy-checkout-disclaimer {
+    gap: 34px;
+  }
+}
+.sunset {
+  width: 100%;
+  height: 15vh;
+  background-repeat: no-repeat;
+  background-position: bottom;
+}
+.curp {
+  cursor: pointer;
+}
+.cura {
+  cursor: auto;
+}
+.w5r {
+  width: 5rem;
+}
+.w30 {
+  width: 30%;
+}
+.w50 {
+  width: 50%;
+}
+.w70 {
+  width: 70%;
+}
+.w100 {
+  width: 100%;
+}
+.mw-680px {
+  max-width: 680px;
+}
+.h15vh {
+  height: 15vh;
+}
+.h100 {
+  height: 100%;
+}
+@media screen and (min-width: 540px) {
+  .sm-w30 {
+    width: 30%;
+  }
+  .sm-w70 {
+    width: 70%;
+  }
+  .sm-w100 {
+    width: 100%;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-w25 {
+    width: 25%;
+  }
+  .md-w30 {
+    width: 30%;
+  }
+  .md-w50 {
+    width: 50%;
+  }
+  .md-w70 {
+    width: 70%;
+  }
+  .md-w100 {
+    width: 100%;
+  }
+  .md-h100 {
+    height: 100%;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-w25 {
+    width: 25%;
+  }
+  .bg-w50 {
+    width: 50%;
+  }
+  .bg-w75 {
+    width: 75%;
+  }
+  .bg-w100 {
+    width: 100%;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-w25 {
+    width: 25%;
+  }
+  .lg-w75 {
+    width: 75%;
+  }
+  .lg-w50 {
+    width: 50%;
+  }
+}
+@media screen and (min-width: 2400px) {
+  .hg-w1200 {
+    width: 1200px;
+  }
+  .hg-x1 {
+    flex: 1;
+  }
+}
+.nav-height {
+  padding-top: 34.7px;
+}
+@media screen and (min-width: 760px) {
+  .nav-height {
+    padding-top: 46.7px;
+  }
+}
+.form-input {
+  padding: 14.5px 10px;
+  background-color: #00120e;
+  border: 1px solid #213f38;
+}
+@media screen and (min-width: 760px) {
+  .sticky-prod-info {
+    position: -webkit-sticky;
+    position: sticky;
+    top: 48px;
+    background-color: var(--c-black);
+    z-index: 1;
+  }
+}
+.icon-p {
+  width: 12px;
+  height: 12px;
+}

+ 1161 - 0
static/dist/styles-DJRXFP6Z.css

@@ -0,0 +1,1161 @@
+/* static/css/styles.css */
+@font-face {
+  font-family: "IBM Plex Mono Light";
+  src:
+    url("./fonts/IBMPlexMono-Light.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-Light.woff") format("woff"),
+    font-style: normal;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "IBM Plex Mono Light Italic";
+  src:
+    url("./fonts/IBMPlexMono-LightItalic.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-LightItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold";
+  src:
+    url("./fonts/Inter-Bold.woff2") format("woff2"),
+    url("./fonts/Inter-Bold.woff") format("woff"),
+    font-style: normal;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold Italic";
+  src:
+    url("./fonts/Inter-BoldItalic.woff2") format("woff2"),
+    url("./fonts/Inter-BoldItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Weiss Rund Gotisch";
+  src:
+    url("./fonts/weissrundgotisch.regular.woff2") format("woff2"),
+    url("./fonts/weissrundgotisch.regular.woff") format("woff"),
+    font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+}
+:root {
+  --nav-height: 35px;
+  --c-white: #ffffff;
+  --c-anon-gray: #a5a5a5;
+  --c-light-gray: #dbdbdb;
+  --c-gray: #b7b7b7;
+  --c-pink: #ffbcfc;
+  --c-dark-bright-brown: #241b18;
+  --c-dark-rubin: #3d230b;
+  --c-rubin: #610000;
+  --c-dark-red: #520000;
+  --c-deep-brown: #2B2222;
+  --c-terre: #AA9494;
+  --c-green: #8dff98;
+  --c-forest-green: #4c8042;
+  --c-light-green: #f2ffcd;
+  --c-light-blue: #bffbff;
+  --c-another-blue: #80d1ff;
+  --c-cyan: #00ffff;
+  --c-bluegreen: #002f10;
+  --c-dark-green: #002C29;
+  --c-dark-bluegreen: #001b1d;
+  --c-blue: #00114c;
+  --c-dark-blue: #00111d;
+  --c-brown: #2f2a00;
+  --c-dark-brown: #1a1d00;
+  --c-gray: #4b4b4b;
+  --c-dark-gray: #555353;
+  --c-bright-black: #020713;
+  --c-gaow: #160E0E;
+  --c-blackish: #11181d;
+  --c-dark-black: #090d10;
+  --c-black: #090909;
+  --c-blackest: #000000;
+  --c-yet-gray: #292929;
+  --f-mono: "IBM Plex Mono Light", monospace;
+  --f-mono-italic: "IBM Plex Mono Light Italic", monospace;
+  --f-sans: "Inter Bold", sans-serif;
+  --f-sans-italic: "Inter Bold Italic", sans-serif;
+  --f-gothic: "Weiss Rund Gotisch", serif;
+}
+@media screen and (min-width: 760px) {
+  :root {
+    --nav-height: 47px;
+  }
+}
+html {
+  box-sizing: border-box;
+  scroll-padding-top: var(--nav-height);
+}
+*,
+*:before,
+*:after {
+  box-sizing: inherit;
+}
+html,
+body {
+  scroll-behavior: smooth;
+}
+html,
+body,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+ol,
+ul,
+li,
+figure {
+  margin: 0;
+  padding: 0;
+}
+figure img {
+  display: block;
+}
+ol,
+ul {
+  padding-left: 1rem;
+}
+img {
+  max-width: 100%;
+  height: auto;
+}
+button {
+  font-family: inherit;
+  font-size: inherit;
+  border: none;
+  border-radius: 0;
+  margin: 0;
+  padding: 0;
+  background: none;
+  color: inherit;
+  cursor: pointer;
+}
+body {
+  -webkit-overflow-scrolling: touch;
+  -ms-overflow-style: none;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  text-rendering: optimizeLegibility;
+  width: 100%;
+  height: 100%;
+}
+html {
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  color: var(--c-white);
+  font-size: 12px;
+  font-family: var(--f-mono);
+}
+a {
+  text-decoration: none;
+  color: inherit;
+}
+input,
+select,
+textarea {
+  background-color: inherit;
+  color: inherit;
+  border: 0;
+  font-family: inherit;
+  font-style: inherit;
+  font-weight: inherit;
+  font-size: inherit;
+  margin: 0;
+  padding: 0;
+}
+input::placeholder,
+textarea::placeholder {
+  opacity: 1;
+  color: #9f879b;
+}
+fieldset {
+  border: 0;
+  padding: 0;
+}
+.fs-small {
+  font-size: 10px;
+}
+.fs-medium {
+  font-size: 1rem;
+}
+.fs-biggish {
+  font-size: 14px;
+}
+.fs-big {
+  font-size: 15px;
+}
+.fs-bigger {
+  font-size: 16px;
+}
+.fs-large {
+  font-size: 18px;
+}
+.fs-larger {
+  font-size: 20px;
+}
+.fs-huge {
+  font-size: 24px;
+}
+.fs-wide {
+  font-size: 30px;
+}
+.ff-sans {
+  font-family: var(--f-sans);
+}
+.ff-sans em,
+.ff-sans italic {
+  font-family: var(--f-sans-italic);
+  font-style: normal;
+}
+.ff-mono em,
+.ff-mono italic {
+  font-family: var(--f-mono-italic);
+  font-style: normal;
+}
+@media screen and (min-width: 540px) {
+  .sm-fs-medium {
+    font-size: 1rem;
+  }
+  .sm-fs-biggish {
+    font-size: 14px;
+  }
+  .sm-fs-big {
+    font-size: 15px;
+  }
+  .sm-fs-large {
+    font-size: 18px;
+  }
+  .sm-fs-larger {
+    font-size: 20px;
+  }
+  .sm-fs-huge {
+    font-size: 24px;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-fs-big {
+    font-size: 15px;
+  }
+}
+.tac {
+  text-align: center;
+}
+.tar {
+  text-align: right;
+}
+@media screen and (min-width: 540px) {
+  .sm-tar {
+    text-align: right;
+  }
+}
+.copy h3,
+.copy h4,
+.copy h5,
+.copy h6,
+.copy p,
+.copy table {
+  max-width: 700px;
+}
+.copy-h-reset-weight h1,
+.copy-h-reset-weight h2,
+.copy-h-reset-weight h3,
+.copy-h-reset-weight h4,
+.copy-h-reset-weight h5,
+.copy-h-reset-weight h6 {
+  font-weight: inherit;
+}
+.copy-h-reset h1,
+.copy-h-reset h2,
+.copy-h-reset h3,
+.copy-h-reset h4,
+.copy-h-reset h5,
+.copy-h-reset h6 {
+  font-size: inherit;
+  font-style: inherit;
+}
+.copy-h h1,
+.copy-h h2 {
+  font-size: 30px;
+  font-family: var(--f-gothic);
+  padding: 15px 20px;
+  border-bottom: 1px solid var(--c-gray);
+}
+@media screen and (min-width: 1920px) {
+  .copy-h-front h1,
+  .copy-h-front h2,
+  .copy-h-page h1,
+  .copy-h-page h2 {
+    padding: 2.5rem 4.2rem;
+  }
+}
+.copy-h-page h3,
+.copy-h-page h4,
+.copy-h-page h5,
+.copy-h-page h6 {
+  font-size: inherit;
+}
+.copy-page h3,
+.copy-page h4,
+.copy-page h5,
+.copy-page h6,
+.copy-page p {
+  padding: 0.75rem 0.75rem 2.5rem 0.7rem;
+}
+.copy-page table {
+  margin: 0 0.75rem 2.45em 0.7rem;
+}
+.copy-page table td {
+  width: 7.5rem;
+}
+.copy a {
+  color: var(--c-cyan);
+}
+.ttu {
+  text-transform: uppercase;
+}
+.hvc:hover {
+  color: var(--c-green);
+}
+.checkout-image {
+  width: 120px;
+}
+@media screen and (min-width: 540px) {
+  .checkout-image {
+    width: 220px;
+  }
+}
+.img-page img {
+  max-height: 1000px;
+}
+@media screen and (min-width: 2400px) {
+  .img-page img {
+    max-height: 12000px;
+  }
+}
+.bd-white {
+  border: 1px solid var(--c-white);
+}
+.bdb-pink {
+  border-bottom: 1px solid var(--c-pink);
+}
+.bdb-dark-green {
+  border-bottom: 1px solid var(--c-dark-green);
+}
+.bd-dark-rubin {
+  border: 1px solid var(--c-dark-rubin);
+}
+.bdb-dark-red {
+  border-bottom: 1px solid var(--c-dark-red);
+}
+.bd-yet-gray {
+  border: 1px solid var(--c-yet-gray);
+}
+.bd-deep-brown {
+  border: 1px solid var(--c-deep-brown);
+}
+.bdt-gray {
+  border-top: 1px solid var(--c-gray);
+}
+.bdb-gray {
+  border-bottom: 1px solid var(--c-gray);
+}
+.bd-dark-gray {
+  border: 1px solid var(--c-dark-gray);
+}
+.bd-brown {
+  border: 1px solid var(--c-brown);
+}
+.bd-blue {
+  border: 1px solid var(--c-blue);
+}
+.bd-bluegreen {
+  border: 1px solid var(--c-bluegreen);
+}
+@media screen and (min-width: 760px) {
+  .md-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+  .md-bdl-rubin {
+    border-left: 1px solid var(--c-rubin);
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+  .bg-bdl-gray {
+    border-left: 1px solid var(--c-gray);
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+  .lg-bdl-gray {
+    border-left: 1px solid var(--c-gray);
+  }
+  .lg-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+}
+.brad-6 {
+  border-radius: 6px;
+}
+.vh {
+  clip: rect(0 0 0 0);
+  -webkit-clip-path: inset(50%);
+  clip-path: inset(50%);
+  height: 1px;
+  overflow: hidden;
+  position: absolute;
+  white-space: nowrap;
+  width: 1px;
+}
+.dn {
+  display: none;
+}
+.dib {
+  display: inline-block;
+}
+.db {
+  display: block;
+}
+.x {
+  display: flex;
+}
+.xw {
+  flex-wrap: wrap;
+}
+.xdr {
+  flex-direction: row;
+}
+.xdc {
+  flex-direction: column;
+}
+.xaic {
+  align-items: center;
+}
+.xaib {
+  align-items: baseline;
+}
+.xjsb {
+  justify-content: space-between;
+}
+.xo1 {
+  order: 1;
+}
+.xo2 {
+  order: 2;
+}
+.xo3 {
+  order: 3;
+}
+@media screen and (min-width: 400px) {
+  .tn-xdr {
+    flex-direction: row;
+  }
+}
+@media screen and (min-width: 540px) {
+  .sm-xaic {
+    align-items: center;
+  }
+  .sm-xo1 {
+    order: 1;
+  }
+  .sm-xo2 {
+    order: 2;
+  }
+  .sm-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-dn {
+    display: none;
+  }
+  .md-db {
+    display: block;
+  }
+  .md-x {
+    display: flex;
+  }
+  .md-xdc {
+    flex-direction: column;
+  }
+  .md-xdr {
+    flex-direction: row;
+  }
+  .md-xw {
+    flex-wrap: wrap;
+  }
+  .md-xo1 {
+    order: 1;
+  }
+  .md-xo2 {
+    order: 2;
+  }
+  .md-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-xw {
+    flex-wrap: wrap;
+  }
+  .bg-xdr {
+    flex-direction: row;
+  }
+  .bg-xjsb {
+    justify-content: space-between;
+  }
+  .bg-xo1 {
+    order: 1;
+  }
+  .bg-xo2 {
+    order: 2;
+  }
+  .bg-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-x {
+    display: flex;
+  }
+  .lg-xdr {
+    flex-direction: row;
+  }
+  .lg-xw {
+    flex-wrap: wrap;
+  }
+  .lg-xdc {
+    flex-direction: column;
+  }
+  .lg-xo1 {
+    order: 1;
+  }
+  .lg-xo2 {
+    order: 2;
+  }
+  .lg-xo3 {
+    order: 3;
+  }
+  .lg-xjsb {
+    justify-content: space-between;
+  }
+}
+.dg {
+  display: grid;
+}
+.z1 {
+  z-index: 1;
+}
+.z2 {
+  z-index: 2;
+}
+.psr {
+  position: relative;
+}
+.psf {
+  position: fixed;
+}
+.psa {
+  position: absolute;
+}
+.t0 {
+  top: 0;
+}
+.b0 {
+  bottom: 0;
+}
+.l0 {
+  left: 0;
+}
+.t50 {
+  top: 50%;
+}
+.l50 {
+  left: 50%;
+}
+.ttcx {
+  transform: translateX(-50%);
+}
+.ttcc {
+  transform: translate(-50%, -50%);
+}
+.mta {
+  margin-top: auto;
+}
+.mla {
+  margin-left: auto;
+}
+.mra {
+  margin-right: auto;
+}
+.mt1 {
+  margin-top: 1rem;
+}
+.mr1 {
+  margin-right: 1rem;
+}
+.mr0-5 {
+  margin-right: 0.5rem;
+}
+.ml0-5 {
+  margin-left: 0.5rem;
+}
+.ml1 {
+  margin-left: 1rem;
+}
+.mb1-25 {
+  margin-bottom: 1.25rem;
+}
+.mb4-25 {
+  margin-bottom: 4.25rem;
+}
+.pt0-25 {
+  padding-top: 0.25rem;
+}
+.pr0-25 {
+  padding-right: 0.25rem;
+}
+.pb0-25 {
+  padding-bottom: 0.25rem;
+}
+.pl0-25 {
+  padding-left: 0.25rem;
+}
+.pt0-5 {
+  padding-top: 0.5rem;
+}
+.pr0-5 {
+  padding-right: 0.5rem;
+}
+.pb0-5 {
+  padding-bottom: 0.5rem;
+}
+.pl0-5 {
+  padding-left: 0.5rem;
+}
+.pt0-75 {
+  padding-top: 0.75rem;
+}
+.pr0-75 {
+  padding-right: 0.75rem;
+}
+.pb0-75 {
+  padding-bottom: 0.75rem;
+}
+.pl0-75 {
+  padding-left: 0.75rem;
+}
+.pt0-85 {
+  padding-top: 0.85rem;
+}
+.pr0-85 {
+  padding-right: 0.85rem;
+}
+.pb0-85 {
+  padding-bottom: 0.85rem;
+}
+.pl0-85 {
+  padding-left: 0.85rem;
+}
+.pt1 {
+  padding-top: 1rem;
+}
+.pb1 {
+  padding-bottom: 1rem;
+}
+.pb1-25 {
+  padding-bottom: 1.25rem;
+}
+.pt1-7 {
+  padding-top: 1.7rem;
+}
+.pb1-7 {
+  padding-bottom: 1.7rem;
+}
+.pb2 {
+  padding-bottom: 2rem;
+}
+.pt3-5 {
+  padding-top: 3.5rem;
+}
+.pb3 {
+  padding-bottom: 3rem;
+}
+.pt4-25 {
+  padding-top: 4.25rem;
+}
+.pb4-25 {
+  padding-bottom: 4.25rem;
+}
+.pt4-75 {
+  padding-top: 4.75rem;
+}
+.pb4-75 {
+  padding-bottom: 4.75rem;
+}
+.pb5-5 {
+  padding-bottom: 5.5rem;
+}
+.pt7 {
+  padding-top: 7rem;
+}
+.pb7 {
+  padding-bottom: 7rem;
+}
+@media screen and (min-width: 540px) {
+  .sm-pr0 {
+    padding-right: 0;
+  }
+  .sm-pl0 {
+    padding-left: 0;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-ml1-25 {
+    margin-left: 1.25rem;
+  }
+  .md-pt0-5 {
+    padding-top: 0.5rem;
+  }
+  .md-pr0-5 {
+    padding-right: 0.5rem;
+  }
+  .md-pb0-5 {
+    padding-bottom: 0.5rem;
+  }
+  .md-pl0-5 {
+    padding-left: 0.5rem;
+  }
+  .md-pt1 {
+    padding-top: 1rem;
+  }
+  .md-pr1 {
+    padding-right: 1rem;
+  }
+  .md-pb1 {
+    padding-bottom: 1rem;
+  }
+  .md-pl1 {
+    padding-left: 1rem;
+  }
+  .md-pr1-3 {
+    padding-right: 1.3rem;
+  }
+  .md-pl1-3 {
+    padding-left: 1.3rem;
+  }
+  .md-pt3-35 {
+    padding-top: 3.35rem;
+  }
+  .md-pr3-35 {
+    padding-right: 3.35rem;
+  }
+  .md-pb3-35 {
+    padding-bottom: 3.35rem;
+  }
+  .md-pl3-35 {
+    padding-left: 3.35rem;
+  }
+  .md-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .md-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .md-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .md-pl4-2 {
+    padding-left: 4.2rem;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .bg-pr8-3 {
+    padding-right: 8.3rem;
+  }
+  .bg-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .bg-pl4-2 {
+    padding-left: 4.2rem;
+  }
+  .bg-pl8-3 {
+    padding-left: 8.3rem;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-pb0 {
+    padding-bottom: 0;
+  }
+  .lg-pr2 {
+    padding-right: 2;
+  }
+  .lg-pt0-42 {
+    padding-top: 0.42rem;
+  }
+  .lg-pr0-42 {
+    padding-right: 0.42rem;
+  }
+  .lg-pb0-42 {
+    padding-bottom: 0.42rem;
+  }
+  .lg-pl0-42 {
+    padding-left: 0.42rem;
+  }
+  .lg-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .lg-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .lg-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .lg-pl4-2 {
+    padding-left: 4.2rem;
+  }
+  .lg-pt8-3 {
+    padding-top: 8.3rem;
+  }
+  .lg-pr8-3 {
+    padding-right: 8.3rem;
+  }
+  .lg-pb8-3 {
+    padding-bottom: 8.3rem;
+  }
+  .lg-pl8-3 {
+    padding-left: 8.3rem;
+  }
+}
+.logo-eye {
+  width: 27px;
+  height: 22.7px;
+}
+.logo-hardware {
+  width: 120px;
+  height: 14px;
+}
+@media screen and (min-width: 760px) {
+  .logo-hardware {
+    width: 160px;
+    height: 18px;
+  }
+}
+.bgc-white {
+  background-color: var(--c-white);
+}
+.bgc-gr-black {
+  background:
+    linear-gradient(
+      180deg,
+      var(--c-blackish) 0%,
+      var(--c-dark-black) 100%);
+  background: var(--c-dark-black);
+}
+.bgc-black {
+  background-color: var(--c-black);
+}
+.bgc-blackest {
+  background-color: var(--c-blackest);
+}
+.bgc-gaow {
+  background-color: var(--c-gaow);
+}
+.bgc-bright-black {
+  background-color: var(--c-bright-black);
+}
+.fgc-terre {
+  color: var(--c-terre);
+}
+.fgc-gray {
+  color: var(--c-gray);
+}
+.fgc-dark-gray {
+  color: var(--c-dark-gray);
+}
+.bgc-dark-brown {
+  background-color: var(--c-dark-brown);
+}
+.bgc-dark-bright-brown {
+  background-color: var(--c-dark-bright-brown);
+}
+.fgc-light-gray {
+  color: var(--c-light-gray);
+}
+.fgc-anon-gray {
+  color: var(--c-anon-gray);
+}
+.fgc-green {
+  color: var(--c-green);
+}
+.fgc-light-blue {
+  color: var(--c-light-blue);
+}
+.bgc-dark-blue {
+  background-color: var(--c-dark-blue);
+}
+.bgc-dark-bluegreen {
+  background-color: var(--c-dark-bluegreen);
+}
+.fgc-cyan {
+  color: var(--c-cyan);
+}
+.btn-product-code {
+  padding: 2px 4px 3px 4px;
+}
+.btn-checkout-remove {
+  padding: 5px 7px;
+}
+.btn-submit {
+  padding: 8px 12px 10px 12px;
+}
+.btn-product-soldout {
+  border: 1px solid var(--c-dark-rubin);
+  background-color: var(--c-dark-bright-brown);
+  color: var(--c-light-gray);
+}
+.btn-product-unselectable {
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-black);
+  color: var(--c-light-gray);
+}
+.btn-product-available {
+  cursor: pointer;
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-bluegreen);
+  color: var(--c-cyan);
+}
+.payment-buttons {
+  gap: 1rem;
+}
+.btn-stripe {
+  width: 88px;
+  height: 42px;
+}
+.btn-nowpayments {
+  width: 170px;
+  height: 42px;
+}
+@media screen and (min-width: 540px) {
+  .btn-stripe {
+    width: 116px;
+    height: 55px;
+  }
+  .btn-nowpayments {
+    width: 220px;
+    height: 55px;
+  }
+}
+.product-size-guide h1,
+.product-size-guide h2,
+.product-size-guide h3,
+.product-size-guide h4,
+.product-size-guide h5,
+.product-size-guide h6 {
+  color: var(--c-another-blue);
+  text-transform: uppercase;
+  padding-bottom: 1.7rem;
+}
+.product-size-guide table {
+  width: 100%;
+}
+.copy table {
+  table-layout: fixed;
+  border-collapse: collapse;
+}
+.copy th,
+.copy td {
+  padding: 4px 0;
+  text-transform: uppercase;
+  font-size: 10px;
+}
+.copy td {
+  border-bottom: 1px solid var(--c-forest-green);
+  text-align: center;
+}
+.copy td:first-child {
+  text-align: left;
+  color: var(--c-light-green);
+}
+.related-products {
+  grid-template-columns: repeat(2, 1fr);
+  overflow: hidden;
+  --gap: 4px;
+  --line-offset: calc(var(--gap) / 2);
+  --line-thickness: 1px;
+  --line-color: var(--c-gray);
+}
+@media screen and (min-width: 760px) {
+  .related-products {
+    grid-template-columns: repeat(4, 1fr);
+  }
+}
+@media screen and (min-width: 2400px) {
+  .related-products {
+    grid-template-columns: repeat(5, 1fr);
+  }
+}
+.related-products::after {
+  content: "";
+  background-color: var(--c-gray);
+  width: 1000%;
+  height: 1px;
+  margin-top: auto;
+}
+.related-products-child {
+  border-bottom: 1px solid var(--c-gray);
+  border-right: 1px solid var(--c-gray);
+}
+.copy-checkout-disclaimer {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 20px;
+}
+@media screen and (min-width: 760px) {
+  .copy-checkout-disclaimer {
+    gap: 34px;
+  }
+}
+.sunset {
+  width: 100%;
+  height: 15vh;
+  background-repeat: no-repeat;
+  background-position: bottom;
+}
+.curp {
+  cursor: pointer;
+}
+.cura {
+  cursor: auto;
+}
+.w5r {
+  width: 5rem;
+}
+.w30 {
+  width: 30%;
+}
+.w50 {
+  width: 50%;
+}
+.w70 {
+  width: 70%;
+}
+.w100 {
+  width: 100%;
+}
+.mw-680px {
+  max-width: 680px;
+}
+.h15vh {
+  height: 15vh;
+}
+.h100 {
+  height: 100%;
+}
+@media screen and (min-width: 540px) {
+  .sm-w30 {
+    width: 30%;
+  }
+  .sm-w70 {
+    width: 70%;
+  }
+  .sm-w100 {
+    width: 100%;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-w25 {
+    width: 25%;
+  }
+  .md-w30 {
+    width: 30%;
+  }
+  .md-w50 {
+    width: 50%;
+  }
+  .md-w70 {
+    width: 70%;
+  }
+  .md-w100 {
+    width: 100%;
+  }
+  .md-h100 {
+    height: 100%;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-w25 {
+    width: 25%;
+  }
+  .bg-w50 {
+    width: 50%;
+  }
+  .bg-w75 {
+    width: 75%;
+  }
+  .bg-w100 {
+    width: 100%;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-w25 {
+    width: 25%;
+  }
+  .lg-w75 {
+    width: 75%;
+  }
+  .lg-w50 {
+    width: 50%;
+  }
+}
+@media screen and (min-width: 2400px) {
+  .hg-w1200 {
+    width: 1200px;
+  }
+  .hg-x1 {
+    flex: 1;
+  }
+}
+.nav-height {
+  padding-top: 34.7px;
+}
+@media screen and (min-width: 760px) {
+  .nav-height {
+    padding-top: 46.7px;
+  }
+}
+.form-input {
+  padding: 14.5px 10px;
+  background-color: #00120e;
+  border: 1px solid #213f38;
+}
+@media screen and (min-width: 760px) {
+  .sticky-prod-info {
+    position: -webkit-sticky;
+    position: sticky;
+    top: 48px;
+  }
+}
+.icon-p {
+  width: 12px;
+  height: 12px;
+}

+ 1126 - 0
static/dist/styles-HTE34KG6.css

@@ -0,0 +1,1126 @@
+/* static/css/styles.css */
+@font-face {
+  font-family: "IBM Plex Mono Light";
+  src:
+    url("./fonts/IBMPlexMono-Light.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-Light.woff") format("woff"),
+    font-style: normal;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "IBM Plex Mono Light Italic";
+  src:
+    url("./fonts/IBMPlexMono-LightItalic.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-LightItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold";
+  src:
+    url("./fonts/Inter-Bold.woff2") format("woff2"),
+    url("./fonts/Inter-Bold.woff") format("woff"),
+    font-style: normal;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold Italic";
+  src:
+    url("./fonts/Inter-BoldItalic.woff2") format("woff2"),
+    url("./fonts/Inter-BoldItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Weiss Rund Gotisch";
+  src:
+    url("./fonts/weissrundgotisch.regular.woff2") format("woff2"),
+    url("./fonts/weissrundgotisch.regular.woff") format("woff"),
+    font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+}
+:root {
+  --nav-height: 35px;
+  --c-white: #ffffff;
+  --c-anon-gray: #a5a5a5;
+  --c-light-gray: #dbdbdb;
+  --c-gray: #b7b7b7;
+  --c-pink: #ffbcfc;
+  --c-dark-bright-brown: #241b18;
+  --c-dark-rubin: #3d230b;
+  --c-rubin: #610000;
+  --c-dark-red: #520000;
+  --c-deep-brown: #2B2222;
+  --c-terre: #AA9494;
+  --c-green: #8dff98;
+  --c-forest-green: #4c8042;
+  --c-light-green: #f2ffcd;
+  --c-light-blue: #bffbff;
+  --c-another-blue: #80d1ff;
+  --c-cyan: #00ffff;
+  --c-bluegreen: #002f10;
+  --c-dark-green: #002C29;
+  --c-dark-bluegreen: #001b1d;
+  --c-blue: #00114c;
+  --c-dark-blue: #00111d;
+  --c-brown: #2f2a00;
+  --c-dark-brown: #1a1d00;
+  --c-gray: #4b4b4b;
+  --c-dark-gray: #555353;
+  --c-bright-black: #020713;
+  --c-gaow: #160E0E;
+  --c-blackish: #11181d;
+  --c-dark-black: #090d10;
+  --c-black: #090909;
+  --c-blackest: #000000;
+  --c-yet-gray: #292929;
+  --f-mono: "IBM Plex Mono Light", monospace;
+  --f-mono-italic: "IBM Plex Mono Light Italic", monospace;
+  --f-sans: "Inter Bold", sans-serif;
+  --f-sans-italic: "Inter Bold Italic", sans-serif;
+  --f-gothic: "Weiss Rund Gotisch", serif;
+}
+@media screen and (min-width: 760px) {
+  :root {
+    --nav-height: 47px;
+  }
+}
+html {
+  box-sizing: border-box;
+  scroll-padding-top: var(--nav-height);
+}
+*,
+*:before,
+*:after {
+  box-sizing: inherit;
+}
+html,
+body {
+  scroll-behavior: smooth;
+}
+html,
+body,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+ol,
+ul,
+li,
+figure {
+  margin: 0;
+  padding: 0;
+}
+figure img {
+  display: block;
+}
+ol,
+ul {
+  padding-left: 1rem;
+}
+img {
+  max-width: 100%;
+  height: auto;
+}
+button {
+  font-family: inherit;
+  font-size: inherit;
+  border: none;
+  border-radius: 0;
+  margin: 0;
+  padding: 0;
+  background: none;
+  color: inherit;
+  cursor: pointer;
+}
+body {
+  -webkit-overflow-scrolling: touch;
+  -ms-overflow-style: none;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  text-rendering: optimizeLegibility;
+  width: 100%;
+  height: 100%;
+}
+html {
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  color: var(--c-white);
+  font-size: 12px;
+  font-family: var(--f-mono);
+}
+a {
+  text-decoration: none;
+  color: inherit;
+}
+input,
+select,
+textarea {
+  background-color: inherit;
+  color: inherit;
+  border: 0;
+  font-family: inherit;
+  font-style: inherit;
+  font-weight: inherit;
+  font-size: inherit;
+  margin: 0;
+  padding: 0;
+}
+input::placeholder,
+textarea::placeholder {
+  opacity: 1;
+  color: #9f879b;
+}
+fieldset {
+  border: 0;
+  padding: 0;
+}
+.fs-small {
+  font-size: 10px;
+}
+.fs-medium {
+  font-size: 1rem;
+}
+.fs-biggish {
+  font-size: 14px;
+}
+.fs-big {
+  font-size: 15px;
+}
+.fs-bigger {
+  font-size: 16px;
+}
+.fs-large {
+  font-size: 18px;
+}
+.fs-larger {
+  font-size: 20px;
+}
+.fs-huge {
+  font-size: 24px;
+}
+.fs-wide {
+  font-size: 30px;
+}
+.ff-sans {
+  font-family: var(--f-sans);
+}
+.ff-sans em,
+.ff-sans italic {
+  font-family: var(--f-sans-italic);
+  font-style: normal;
+}
+.ff-mono em,
+.ff-mono italic {
+  font-family: var(--f-mono-italic);
+  font-style: normal;
+}
+@media screen and (min-width: 540px) {
+  .sm-fs-medium {
+    font-size: 1rem;
+  }
+  .sm-fs-biggish {
+    font-size: 14px;
+  }
+  .sm-fs-big {
+    font-size: 15px;
+  }
+  .sm-fs-large {
+    font-size: 18px;
+  }
+  .sm-fs-larger {
+    font-size: 20px;
+  }
+  .sm-fs-huge {
+    font-size: 24px;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-fs-big {
+    font-size: 15px;
+  }
+}
+.tac {
+  text-align: center;
+}
+.tar {
+  text-align: right;
+}
+@media screen and (min-width: 540px) {
+  .sm-tar {
+    text-align: right;
+  }
+}
+.copy h3,
+.copy h4,
+.copy h5,
+.copy h6,
+.copy p,
+.copy table {
+  max-width: 700px;
+}
+.copy-h-reset-weight h1,
+.copy-h-reset-weight h2,
+.copy-h-reset-weight h3,
+.copy-h-reset-weight h4,
+.copy-h-reset-weight h5,
+.copy-h-reset-weight h6 {
+  font-weight: inherit;
+}
+.copy-h-reset h1,
+.copy-h-reset h2,
+.copy-h-reset h3,
+.copy-h-reset h4,
+.copy-h-reset h5,
+.copy-h-reset h6 {
+  font-size: inherit;
+  font-style: inherit;
+}
+.copy-h h1,
+.copy-h h2 {
+  font-size: 30px;
+  font-family: var(--f-gothic);
+  padding: 15px 20px;
+  border-bottom: 1px solid var(--c-gray);
+}
+@media screen and (min-width: 1920px) {
+  .copy-h-front h1,
+  .copy-h-front h2,
+  .copy-h-page h1,
+  .copy-h-page h2 {
+    padding: 2.5rem 4.2rem;
+  }
+}
+.copy-h-page h3,
+.copy-h-page h4,
+.copy-h-page h5,
+.copy-h-page h6 {
+  font-size: inherit;
+}
+.copy-page h3,
+.copy-page h4,
+.copy-page h5,
+.copy-page h6,
+.copy-page p {
+  padding: 0.75rem 0.75rem 2.5rem 0.7rem;
+}
+.copy-page table {
+  margin: 0 0.75rem 2.45em 0.7rem;
+}
+.copy-page table td {
+  width: 7.5rem;
+}
+.copy a {
+  color: var(--c-cyan);
+}
+.ttu {
+  text-transform: uppercase;
+}
+.hvc:hover {
+  color: var(--c-green);
+}
+.checkout-image {
+  width: 120px;
+}
+@media screen and (min-width: 540px) {
+  .checkout-image {
+    width: 220px;
+  }
+}
+.img-page img {
+  max-height: 1000px;
+}
+@media screen and (min-width: 2400px) {
+  .img-page img {
+    max-height: 12000px;
+  }
+}
+.bd-white {
+  border: 1px solid var(--c-white);
+}
+.bdb-pink {
+  border-bottom: 1px solid var(--c-pink);
+}
+.bdb-dark-green {
+  border-bottom: 1px solid var(--c-dark-green);
+}
+.bd-dark-rubin {
+  border: 1px solid var(--c-dark-rubin);
+}
+.bdb-dark-red {
+  border-bottom: 1px solid var(--c-dark-red);
+}
+.bd-yet-gray {
+  border: 1px solid var(--c-yet-gray);
+}
+.bd-deep-brown {
+  border: 1px solid var(--c-deep-brown);
+}
+.bdt-gray {
+  border-top: 1px solid var(--c-gray);
+}
+.bdb-gray {
+  border-bottom: 1px solid var(--c-gray);
+}
+.bd-dark-gray {
+  border: 1px solid var(--c-dark-gray);
+}
+.bd-brown {
+  border: 1px solid var(--c-brown);
+}
+.bd-blue {
+  border: 1px solid var(--c-blue);
+}
+.bd-bluegreen {
+  border: 1px solid var(--c-bluegreen);
+}
+@media screen and (min-width: 760px) {
+  .md-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+  .md-bdl-rubin {
+    border-left: 1px solid var(--c-rubin);
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+  .lg-bdl-gray {
+    border-left: 1px solid var(--c-gray);
+  }
+  .lg-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+}
+.brad-6 {
+  border-radius: 6px;
+}
+.vh {
+  clip: rect(0 0 0 0);
+  -webkit-clip-path: inset(50%);
+  clip-path: inset(50%);
+  height: 1px;
+  overflow: hidden;
+  position: absolute;
+  white-space: nowrap;
+  width: 1px;
+}
+.dn {
+  display: none;
+}
+.dib {
+  display: inline-block;
+}
+.db {
+  display: block;
+}
+.x {
+  display: flex;
+}
+.xw {
+  flex-wrap: wrap;
+}
+.xdr {
+  flex-direction: row;
+}
+.xdc {
+  flex-direction: column;
+}
+.xaic {
+  align-items: center;
+}
+.xaib {
+  align-items: baseline;
+}
+.xjsb {
+  justify-content: space-between;
+}
+.xo1 {
+  order: 1;
+}
+.xo2 {
+  order: 2;
+}
+.xo3 {
+  order: 3;
+}
+@media screen and (min-width: 400px) {
+  .tn-xdr {
+    flex-direction: row;
+  }
+}
+@media screen and (min-width: 540px) {
+  .sm-xaic {
+    align-items: center;
+  }
+  .sm-xo1 {
+    order: 1;
+  }
+  .sm-xo2 {
+    order: 2;
+  }
+  .sm-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-dn {
+    display: none;
+  }
+  .md-db {
+    display: block;
+  }
+  .md-x {
+    display: flex;
+  }
+  .md-xdc {
+    flex-direction: column;
+  }
+  .md-xdr {
+    flex-direction: row;
+  }
+  .md-xw {
+    flex-wrap: wrap;
+  }
+  .md-xo1 {
+    order: 1;
+  }
+  .md-xo2 {
+    order: 2;
+  }
+  .md-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-xdr {
+    flex-direction: row;
+  }
+  .bg-xo1 {
+    order: 1;
+  }
+  .bg-xo2 {
+    order: 2;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-x {
+    display: flex;
+  }
+  .lg-xdr {
+    flex-direction: row;
+  }
+  .lg-xw {
+    flex-wrap: wrap;
+  }
+  .lg-xdc {
+    flex-direction: column;
+  }
+  .lg-xo1 {
+    order: 1;
+  }
+  .lg-xo2 {
+    order: 2;
+  }
+  .lg-xo3 {
+    order: 3;
+  }
+  .lg-xjsb {
+    justify-content: space-between;
+  }
+}
+.dg {
+  display: grid;
+}
+.z1 {
+  z-index: 1;
+}
+.z2 {
+  z-index: 2;
+}
+.psr {
+  position: relative;
+}
+.psf {
+  position: fixed;
+}
+.psa {
+  position: absolute;
+}
+.t0 {
+  top: 0;
+}
+.b0 {
+  bottom: 0;
+}
+.l0 {
+  left: 0;
+}
+.t50 {
+  top: 50%;
+}
+.l50 {
+  left: 50%;
+}
+.ttcx {
+  transform: translateX(-50%);
+}
+.ttcc {
+  transform: translate(-50%, -50%);
+}
+.mta {
+  margin-top: auto;
+}
+.mla {
+  margin-left: auto;
+}
+.mra {
+  margin-right: auto;
+}
+.mt1 {
+  margin-top: 1rem;
+}
+.mr1 {
+  margin-right: 1rem;
+}
+.mr0-5 {
+  margin-right: 0.5rem;
+}
+.ml0-5 {
+  margin-left: 0.5rem;
+}
+.ml1 {
+  margin-left: 1rem;
+}
+.mb1-25 {
+  margin-bottom: 1.25rem;
+}
+.mb4-25 {
+  margin-bottom: 4.25rem;
+}
+.pt0-25 {
+  padding-top: 0.25rem;
+}
+.pr0-25 {
+  padding-right: 0.25rem;
+}
+.pb0-25 {
+  padding-bottom: 0.25rem;
+}
+.pl0-25 {
+  padding-left: 0.25rem;
+}
+.pt0-5 {
+  padding-top: 0.5rem;
+}
+.pr0-5 {
+  padding-right: 0.5rem;
+}
+.pb0-5 {
+  padding-bottom: 0.5rem;
+}
+.pl0-5 {
+  padding-left: 0.5rem;
+}
+.pt0-75 {
+  padding-top: 0.75rem;
+}
+.pr0-75 {
+  padding-right: 0.75rem;
+}
+.pb0-75 {
+  padding-bottom: 0.75rem;
+}
+.pl0-75 {
+  padding-left: 0.75rem;
+}
+.pt0-85 {
+  padding-top: 0.85rem;
+}
+.pr0-85 {
+  padding-right: 0.85rem;
+}
+.pb0-85 {
+  padding-bottom: 0.85rem;
+}
+.pl0-85 {
+  padding-left: 0.85rem;
+}
+.pt1 {
+  padding-top: 1rem;
+}
+.pb1 {
+  padding-bottom: 1rem;
+}
+.pb1-25 {
+  padding-bottom: 1.25rem;
+}
+.pt1-7 {
+  padding-top: 1.7rem;
+}
+.pb1-7 {
+  padding-bottom: 1.7rem;
+}
+.pb2 {
+  padding-bottom: 2rem;
+}
+.pt3-5 {
+  padding-top: 3.5rem;
+}
+.pb3 {
+  padding-bottom: 3rem;
+}
+.pt4-25 {
+  padding-top: 4.25rem;
+}
+.pb4-25 {
+  padding-bottom: 4.25rem;
+}
+.pt4-75 {
+  padding-top: 4.75rem;
+}
+.pb4-75 {
+  padding-bottom: 4.75rem;
+}
+.pb5-5 {
+  padding-bottom: 5.5rem;
+}
+.pt7 {
+  padding-top: 7rem;
+}
+.pb7 {
+  padding-bottom: 7rem;
+}
+@media screen and (min-width: 540px) {
+  .sm-pr0 {
+    padding-right: 0;
+  }
+  .sm-pl0 {
+    padding-left: 0;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-ml1-25 {
+    margin-left: 1.25rem;
+  }
+  .md-pt0-5 {
+    padding-top: 0.5rem;
+  }
+  .md-pr0-5 {
+    padding-right: 0.5rem;
+  }
+  .md-pb0-5 {
+    padding-bottom: 0.5rem;
+  }
+  .md-pl0-5 {
+    padding-left: 0.5rem;
+  }
+  .md-pt1 {
+    padding-top: 1rem;
+  }
+  .md-pr1 {
+    padding-right: 1rem;
+  }
+  .md-pb1 {
+    padding-bottom: 1rem;
+  }
+  .md-pl1 {
+    padding-left: 1rem;
+  }
+  .md-pr1-3 {
+    padding-right: 1.3rem;
+  }
+  .md-pl1-3 {
+    padding-left: 1.3rem;
+  }
+  .md-pt3-35 {
+    padding-top: 3.35rem;
+  }
+  .md-pr3-35 {
+    padding-right: 3.35rem;
+  }
+  .md-pb3-35 {
+    padding-bottom: 3.35rem;
+  }
+  .md-pl3-35 {
+    padding-left: 3.35rem;
+  }
+  .md-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .md-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .md-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .md-pl4-2 {
+    padding-left: 4.2rem;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-pb0 {
+    padding-bottom: 0;
+  }
+  .lg-pr2 {
+    padding-right: 2;
+  }
+  .lg-pt0-42 {
+    padding-top: 0.42rem;
+  }
+  .lg-pr0-42 {
+    padding-right: 0.42rem;
+  }
+  .lg-pb0-42 {
+    padding-bottom: 0.42rem;
+  }
+  .lg-pl0-42 {
+    padding-left: 0.42rem;
+  }
+  .lg-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .lg-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .lg-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .lg-pl4-2 {
+    padding-left: 4.2rem;
+  }
+  .lg-pt8-3 {
+    padding-top: 8.3rem;
+  }
+  .lg-pr8-3 {
+    padding-right: 8.3rem;
+  }
+  .lg-pb8-3 {
+    padding-bottom: 8.3rem;
+  }
+  .lg-pl8-3 {
+    padding-left: 8.3rem;
+  }
+}
+.logo-eye {
+  width: 27px;
+  height: 22.7px;
+}
+.logo-hardware {
+  width: 120px;
+  height: 14px;
+}
+@media screen and (min-width: 760px) {
+  .logo-hardware {
+    width: 160px;
+    height: 18px;
+  }
+}
+.bgc-white {
+  background-color: var(--c-white);
+}
+.bgc-gr-black {
+  background:
+    linear-gradient(
+      180deg,
+      var(--c-blackish) 0%,
+      var(--c-dark-black) 100%);
+  background: var(--c-dark-black);
+}
+.bgc-black {
+  background-color: var(--c-black);
+}
+.bgc-blackest {
+  background-color: var(--c-blackest);
+}
+.bgc-gaow {
+  background-color: var(--c-gaow);
+}
+.bgc-bright-black {
+  background-color: var(--c-bright-black);
+}
+.fgc-terre {
+  color: var(--c-terre);
+}
+.fgc-gray {
+  color: var(--c-gray);
+}
+.fgc-dark-gray {
+  color: var(--c-dark-gray);
+}
+.bgc-dark-brown {
+  background-color: var(--c-dark-brown);
+}
+.bgc-dark-bright-brown {
+  background-color: var(--c-dark-bright-brown);
+}
+.fgc-light-gray {
+  color: var(--c-light-gray);
+}
+.fgc-anon-gray {
+  color: var(--c-anon-gray);
+}
+.fgc-green {
+  color: var(--c-green);
+}
+.fgc-light-blue {
+  color: var(--c-light-blue);
+}
+.bgc-dark-blue {
+  background-color: var(--c-dark-blue);
+}
+.bgc-dark-bluegreen {
+  background-color: var(--c-dark-bluegreen);
+}
+.fgc-cyan {
+  color: var(--c-cyan);
+}
+.btn-product-code {
+  padding: 2px 4px 3px 4px;
+}
+.btn-checkout-remove {
+  padding: 5px 7px;
+}
+.btn-submit {
+  padding: 8px 12px 10px 12px;
+}
+.btn-product-soldout {
+  border: 1px solid var(--c-dark-rubin);
+  background-color: var(--c-dark-bright-brown);
+  color: var(--c-light-gray);
+}
+.btn-product-unselectable {
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-black);
+  color: var(--c-light-gray);
+}
+.btn-product-available {
+  cursor: pointer;
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-bluegreen);
+  color: var(--c-cyan);
+}
+.payment-buttons {
+  gap: 1rem;
+}
+.btn-stripe {
+  width: 88px;
+  height: 42px;
+}
+.btn-nowpayments {
+  width: 170px;
+  height: 42px;
+}
+@media screen and (min-width: 540px) {
+  .btn-stripe {
+    width: 116px;
+    height: 55px;
+  }
+  .btn-nowpayments {
+    width: 220px;
+    height: 55px;
+  }
+}
+.product-size-guide h1,
+.product-size-guide h2,
+.product-size-guide h3,
+.product-size-guide h4,
+.product-size-guide h5,
+.product-size-guide h6 {
+  color: var(--c-another-blue);
+  text-transform: uppercase;
+  padding-bottom: 1.7rem;
+}
+.product-size-guide table {
+  width: 100%;
+}
+.copy table {
+  table-layout: fixed;
+  border-collapse: collapse;
+}
+.copy th,
+.copy td {
+  padding: 4px 0;
+  text-transform: uppercase;
+  font-size: 10px;
+}
+.copy td {
+  border-bottom: 1px solid var(--c-forest-green);
+  text-align: center;
+}
+.copy td:first-child {
+  text-align: left;
+  color: var(--c-light-green);
+}
+.related-products {
+  grid-template-columns: repeat(2, 1fr);
+  overflow: hidden;
+  --gap: 4px;
+  --line-offset: calc(var(--gap) / 2);
+  --line-thickness: 1px;
+  --line-color: var(--c-gray);
+}
+@media screen and (min-width: 760px) {
+  .related-products {
+    grid-template-columns: repeat(4, 1fr);
+  }
+}
+@media screen and (min-width: 2400px) {
+  .related-products {
+    grid-template-columns: repeat(5, 1fr);
+  }
+}
+.related-products::after {
+  content: "";
+  background-color: var(--c-gray);
+  width: 1000%;
+  height: 1px;
+  margin-top: auto;
+}
+.related-products-child {
+  border-bottom: 1px solid var(--c-gray);
+  border-right: 1px solid var(--c-gray);
+}
+.copy-checkout-disclaimer {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 20px;
+}
+@media screen and (min-width: 760px) {
+  .copy-checkout-disclaimer {
+    gap: 34px;
+  }
+}
+.sunset {
+  width: 100%;
+  height: 15vh;
+  background-repeat: no-repeat;
+  background-position: bottom;
+}
+.curp {
+  cursor: pointer;
+}
+.cura {
+  cursor: auto;
+}
+.w5r {
+  width: 5rem;
+}
+.w30 {
+  width: 30%;
+}
+.w50 {
+  width: 50%;
+}
+.w70 {
+  width: 70%;
+}
+.w100 {
+  width: 100%;
+}
+.mw-680px {
+  max-width: 680px;
+}
+.h15vh {
+  height: 15vh;
+}
+.h100 {
+  height: 100%;
+}
+@media screen and (min-width: 540px) {
+  .sm-w30 {
+    width: 30%;
+  }
+  .sm-w70 {
+    width: 70%;
+  }
+  .sm-w100 {
+    width: 100%;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-w25 {
+    width: 25%;
+  }
+  .md-w30 {
+    width: 30%;
+  }
+  .md-w50 {
+    width: 50%;
+  }
+  .md-w70 {
+    width: 70%;
+  }
+  .md-w100 {
+    width: 100%;
+  }
+  .md-h100 {
+    height: 100%;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-w25 {
+    width: 25%;
+  }
+  .bg-w75 {
+    width: 75%;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-w25 {
+    width: 25%;
+  }
+  .lg-w75 {
+    width: 75%;
+  }
+  .lg-w50 {
+    width: 50%;
+  }
+}
+@media screen and (min-width: 2400px) {
+  .hg-w1200 {
+    width: 1200px;
+  }
+  .hg-x1 {
+    flex: 1;
+  }
+}
+.nav-height {
+  padding-top: 34.7px;
+}
+@media screen and (min-width: 760px) {
+  .nav-height {
+    padding-top: 46.7px;
+  }
+}
+.form-input {
+  padding: 14.5px 10px;
+  background-color: #00120e;
+  border: 1px solid #213f38;
+}
+@media screen and (min-width: 760px) {
+  .sticky-prod-info {
+    position: -webkit-sticky;
+    position: sticky;
+    top: 48px;
+  }
+}
+.icon-p {
+  width: 12px;
+  height: 12px;
+}

+ 1082 - 0
static/dist/styles-JIS64YTB.css

@@ -0,0 +1,1082 @@
+/* static/css/styles.css */
+@font-face {
+  font-family: "IBM Plex Mono Light";
+  src:
+    url("./fonts/IBMPlexMono-Light.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-Light.woff") format("woff"),
+    font-style: normal;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "IBM Plex Mono Light Italic";
+  src:
+    url("./fonts/IBMPlexMono-LightItalic.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-LightItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold";
+  src:
+    url("./fonts/Inter-Bold.woff2") format("woff2"),
+    url("./fonts/Inter-Bold.woff") format("woff"),
+    font-style: normal;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold Italic";
+  src:
+    url("./fonts/Inter-BoldItalic.woff2") format("woff2"),
+    url("./fonts/Inter-BoldItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Weiss Rund Gotisch";
+  src:
+    url("./fonts/weissrundgotisch.regular.woff2") format("woff2"),
+    url("./fonts/weissrundgotisch.regular.woff") format("woff"),
+    font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+}
+:root {
+  --nav-height: 35px;
+  --c-white: #ffffff;
+  --c-anon-gray: #a5a5a5;
+  --c-light-gray: #dbdbdb;
+  --c-gray: #b7b7b7;
+  --c-pink: #ffbcfc;
+  --c-dark-bright-brown: #241b18;
+  --c-dark-rubin: #3d230b;
+  --c-rubin: #610000;
+  --c-dark-red: #520000;
+  --c-deep-brown: #2B2222;
+  --c-terre: #AA9494;
+  --c-green: #8dff98;
+  --c-forest-green: #4c8042;
+  --c-light-green: #f2ffcd;
+  --c-light-blue: #bffbff;
+  --c-another-blue: #80d1ff;
+  --c-cyan: #00ffff;
+  --c-bluegreen: #002f10;
+  --c-dark-green: #002C29;
+  --c-dark-bluegreen: #001b1d;
+  --c-blue: #00114c;
+  --c-dark-blue: #00111d;
+  --c-brown: #2f2a00;
+  --c-dark-brown: #1a1d00;
+  --c-gray: #4b4b4b;
+  --c-dark-gray: #555353;
+  --c-bright-black: #020713;
+  --c-gaow: #160E0E;
+  --c-blackish: #11181d;
+  --c-dark-black: #090d10;
+  --c-black: #090909;
+  --c-yet-gray: #292929;
+  --f-mono: "IBM Plex Mono Light", monospace;
+  --f-mono-italic: "IBM Plex Mono Light Italic", monospace;
+  --f-sans: "Inter Bold", sans-serif;
+  --f-sans-italic: "Inter Bold Italic", sans-serif;
+  --f-gothic: "Weiss Rund Gotisch", serif;
+}
+@media screen and (min-width: 760px) {
+  :root {
+    --nav-height: 47px;
+  }
+}
+html {
+  box-sizing: border-box;
+  scroll-padding-top: var(--nav-height);
+}
+*,
+*:before,
+*:after {
+  box-sizing: inherit;
+}
+html,
+body {
+  scroll-behavior: smooth;
+}
+html,
+body,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+ol,
+ul,
+li,
+figure {
+  margin: 0;
+  padding: 0;
+}
+figure img {
+  display: block;
+}
+ol,
+ul {
+  padding-left: 1rem;
+}
+img {
+  max-width: 100%;
+  height: auto;
+}
+button {
+  font-family: inherit;
+  font-size: inherit;
+  border: none;
+  border-radius: 0;
+  margin: 0;
+  padding: 0;
+  background: none;
+  color: inherit;
+  cursor: pointer;
+}
+body {
+  -webkit-overflow-scrolling: touch;
+  -ms-overflow-style: none;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  text-rendering: optimizeLegibility;
+  width: 100%;
+  height: 100%;
+}
+html {
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  color: var(--c-white);
+  font-size: 12px;
+  font-family: var(--f-mono);
+}
+a {
+  text-decoration: none;
+  color: inherit;
+}
+input,
+select,
+textarea {
+  background-color: inherit;
+  color: inherit;
+  border: 0;
+  font-family: inherit;
+  font-style: inherit;
+  font-weight: inherit;
+  font-size: inherit;
+  margin: 0;
+  padding: 0;
+}
+input::placeholder,
+textarea::placeholder {
+  opacity: 1;
+  color: #9f879b;
+}
+fieldset {
+  border: 0;
+  padding: 0;
+}
+.fs-small {
+  font-size: 10px;
+}
+.fs-medium {
+  font-size: 1rem;
+}
+.fs-biggish {
+  font-size: 14px;
+}
+.fs-big {
+  font-size: 15px;
+}
+.fs-bigger {
+  font-size: 16px;
+}
+.fs-large {
+  font-size: 18px;
+}
+.fs-larger {
+  font-size: 20px;
+}
+.fs-huge {
+  font-size: 24px;
+}
+.fs-wide {
+  font-size: 30px;
+}
+.ff-sans {
+  font-family: var(--f-sans);
+}
+.ff-sans em,
+.ff-sans italic {
+  font-family: var(--f-sans-italic);
+  font-style: normal;
+}
+.ff-mono em,
+.ff-mono italic {
+  font-family: var(--f-mono-italic);
+  font-style: normal;
+}
+@media screen and (min-width: 540px) {
+  .sm-fs-medium {
+    font-size: 1rem;
+  }
+  .sm-fs-biggish {
+    font-size: 14px;
+  }
+  .sm-fs-big {
+    font-size: 15px;
+  }
+  .sm-fs-large {
+    font-size: 18px;
+  }
+  .sm-fs-larger {
+    font-size: 20px;
+  }
+  .sm-fs-huge {
+    font-size: 24px;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-fs-big {
+    font-size: 15px;
+  }
+}
+.tac {
+  text-align: center;
+}
+.tar {
+  text-align: right;
+}
+@media screen and (min-width: 540px) {
+  .sm-tar {
+    text-align: right;
+  }
+}
+.copy h3,
+.copy h4,
+.copy h5,
+.copy h6,
+.copy p,
+.copy table {
+  max-width: 700px;
+}
+.copy-h-reset-weight h1,
+.copy-h-reset-weight h2,
+.copy-h-reset-weight h3,
+.copy-h-reset-weight h4,
+.copy-h-reset-weight h5,
+.copy-h-reset-weight h6 {
+  font-weight: inherit;
+}
+.copy-h-reset h1,
+.copy-h-reset h2,
+.copy-h-reset h3,
+.copy-h-reset h4,
+.copy-h-reset h5,
+.copy-h-reset h6 {
+  font-size: inherit;
+  font-style: inherit;
+}
+.copy-h h1,
+.copy-h h2 {
+  font-size: 30px;
+  font-family: var(--f-gothic);
+  padding: 15px 20px;
+  border-bottom: 1px solid var(--c-gray);
+}
+@media screen and (min-width: 1920px) {
+  .copy-h-front h1,
+  .copy-h-front h2,
+  .copy-h-page h1,
+  .copy-h-page h2 {
+    padding: 2.5rem 4.2rem;
+  }
+}
+.copy-h-page h3,
+.copy-h-page h4,
+.copy-h-page h5,
+.copy-h-page h6 {
+  font-size: inherit;
+}
+.copy-page h3,
+.copy-page h4,
+.copy-page h5,
+.copy-page h6,
+.copy-page p {
+  padding: 0.75rem 0.75rem 2.5rem 0.7rem;
+}
+.copy-page table {
+  margin: 0 0.75rem 2.45em 0.7rem;
+}
+.copy-page table td {
+  width: 7.5rem;
+}
+.copy a {
+  color: var(--c-cyan);
+}
+.ttu {
+  text-transform: uppercase;
+}
+.hvc:hover {
+  color: var(--c-green);
+}
+.checkout-image {
+  width: 120px;
+}
+@media screen and (min-width: 540px) {
+  .checkout-image {
+    width: 220px;
+  }
+}
+.img-page img {
+  max-height: 1000px;
+}
+@media screen and (min-width: 2400px) {
+  .img-page img {
+    max-height: 12000px;
+  }
+}
+.bd-white {
+  border: 1px solid var(--c-white);
+}
+.bdb-pink {
+  border-bottom: 1px solid var(--c-pink);
+}
+.bdb-dark-green {
+  border-bottom: 1px solid var(--c-dark-green);
+}
+.bd-dark-rubin {
+  border: 1px solid var(--c-dark-rubin);
+}
+.bdb-dark-red {
+  border-bottom: 1px solid var(--c-dark-red);
+}
+.bd-yet-gray {
+  border: 1px solid var(--c-yet-gray);
+}
+.bd-deep-brown {
+  border: 1px solid var(--c-deep-brown);
+}
+.bdt-gray {
+  border-top: 1px solid var(--c-gray);
+}
+.bdb-gray {
+  border-bottom: 1px solid var(--c-gray);
+}
+.bd-dark-gray {
+  border: 1px solid var(--c-dark-gray);
+}
+.bd-brown {
+  border: 1px solid var(--c-brown);
+}
+.bd-blue {
+  border: 1px solid var(--c-blue);
+}
+.bd-bluegreen {
+  border: 1px solid var(--c-bluegreen);
+}
+@media screen and (min-width: 760px) {
+  .md-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+  .md-bdl-rubin {
+    border-left: 1px solid var(--c-rubin);
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+  .lg-bdl-gray {
+    border-left: 1px solid var(--c-gray);
+  }
+  .lg-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+}
+.brad-6 {
+  border-radius: 6px;
+}
+.vh {
+  clip: rect(0 0 0 0);
+  -webkit-clip-path: inset(50%);
+  clip-path: inset(50%);
+  height: 1px;
+  overflow: hidden;
+  position: absolute;
+  white-space: nowrap;
+  width: 1px;
+}
+.dn {
+  display: none;
+}
+.dib {
+  display: inline-block;
+}
+.db {
+  display: block;
+}
+.x {
+  display: flex;
+}
+.xw {
+  flex-wrap: wrap;
+}
+.xdr {
+  flex-direction: row;
+}
+.xdc {
+  flex-direction: column;
+}
+.xaic {
+  align-items: center;
+}
+.xaib {
+  align-items: baseline;
+}
+.xjsb {
+  justify-content: space-between;
+}
+.xo1 {
+  order: 1;
+}
+.xo2 {
+  order: 2;
+}
+.xo3 {
+  order: 3;
+}
+@media screen and (min-width: 400px) {
+  .tn-xdr {
+    flex-direction: row;
+  }
+}
+@media screen and (min-width: 540px) {
+  .sm-xaic {
+    align-items: center;
+  }
+  .sm-xo1 {
+    order: 1;
+  }
+  .sm-xo2 {
+    order: 2;
+  }
+  .sm-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-dn {
+    display: none;
+  }
+  .md-db {
+    display: block;
+  }
+  .md-x {
+    display: flex;
+  }
+  .md-xdc {
+    flex-direction: column;
+  }
+  .md-xdr {
+    flex-direction: row;
+  }
+  .md-xw {
+    flex-wrap: wrap;
+  }
+  .md-xo1 {
+    order: 1;
+  }
+  .md-xo2 {
+    order: 2;
+  }
+  .md-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-x {
+    display: flex;
+  }
+  .lg-xdr {
+    flex-direction: row;
+  }
+  .lg-xw {
+    flex-wrap: wrap;
+  }
+  .lg-xdc {
+    flex-direction: column;
+  }
+  .lg-xo1 {
+    order: 1;
+  }
+  .lg-xo2 {
+    order: 2;
+  }
+  .lg-xo3 {
+    order: 3;
+  }
+  .lg-xjsb {
+    justify-content: space-between;
+  }
+}
+.dg {
+  display: grid;
+}
+.z1 {
+  z-index: 1;
+}
+.z2 {
+  z-index: 2;
+}
+.psr {
+  position: relative;
+}
+.psf {
+  position: fixed;
+}
+.psa {
+  position: absolute;
+}
+.t0 {
+  top: 0;
+}
+.b0 {
+  bottom: 0;
+}
+.l0 {
+  left: 0;
+}
+.t50 {
+  top: 50%;
+}
+.l50 {
+  left: 50%;
+}
+.ttcx {
+  transform: translateX(-50%);
+}
+.ttcc {
+  transform: translate(-50%, -50%);
+}
+.mta {
+  margin-top: auto;
+}
+.mla {
+  margin-left: auto;
+}
+.mra {
+  margin-right: auto;
+}
+.mt1 {
+  margin-top: 1rem;
+}
+.mr1 {
+  margin-right: 1rem;
+}
+.mr0-5 {
+  margin-right: 0.5rem;
+}
+.ml0-5 {
+  margin-left: 0.5rem;
+}
+.ml1 {
+  margin-left: 1rem;
+}
+.mb1-25 {
+  margin-bottom: 1.25rem;
+}
+.mb4-25 {
+  margin-bottom: 4.25rem;
+}
+.pt0-25 {
+  padding-top: 0.25rem;
+}
+.pr0-25 {
+  padding-right: 0.25rem;
+}
+.pb0-25 {
+  padding-bottom: 0.25rem;
+}
+.pl0-25 {
+  padding-left: 0.25rem;
+}
+.pt0-5 {
+  padding-top: 0.5rem;
+}
+.pr0-5 {
+  padding-right: 0.5rem;
+}
+.pb0-5 {
+  padding-bottom: 0.5rem;
+}
+.pl0-5 {
+  padding-left: 0.5rem;
+}
+.pt0-75 {
+  padding-top: 0.75rem;
+}
+.pr0-75 {
+  padding-right: 0.75rem;
+}
+.pb0-75 {
+  padding-bottom: 0.75rem;
+}
+.pl0-75 {
+  padding-left: 0.75rem;
+}
+.pt0-85 {
+  padding-top: 0.85rem;
+}
+.pr0-85 {
+  padding-right: 0.85rem;
+}
+.pb0-85 {
+  padding-bottom: 0.85rem;
+}
+.pl0-85 {
+  padding-left: 0.85rem;
+}
+.pt1 {
+  padding-top: 1rem;
+}
+.pb1 {
+  padding-bottom: 1rem;
+}
+.pb1-25 {
+  padding-bottom: 1.25rem;
+}
+.pt1-7 {
+  padding-top: 1.7rem;
+}
+.pb1-7 {
+  padding-bottom: 1.7rem;
+}
+.pb2 {
+  padding-bottom: 2rem;
+}
+.pt3-5 {
+  padding-top: 3.5rem;
+}
+.pb3 {
+  padding-bottom: 3rem;
+}
+.pt4-25 {
+  padding-top: 4.25rem;
+}
+.pb4-25 {
+  padding-bottom: 4.25rem;
+}
+.pt4-75 {
+  padding-top: 4.75rem;
+}
+.pb4-75 {
+  padding-bottom: 4.75rem;
+}
+.pb5-5 {
+  padding-bottom: 5.5rem;
+}
+.pt7 {
+  padding-top: 7rem;
+}
+.pb7 {
+  padding-bottom: 7rem;
+}
+@media screen and (min-width: 540px) {
+  .sm-pr0 {
+    padding-right: 0;
+  }
+  .sm-pl0 {
+    padding-left: 0;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-pt0-5 {
+    padding-top: 0.5rem;
+  }
+  .md-pr0-5 {
+    padding-right: 0.5rem;
+  }
+  .md-pb0-5 {
+    padding-bottom: 0.5rem;
+  }
+  .md-pl0-5 {
+    padding-left: 0.5rem;
+  }
+  .md-pt1 {
+    padding-top: 1rem;
+  }
+  .md-pr1 {
+    padding-right: 1rem;
+  }
+  .md-pb1 {
+    padding-bottom: 1rem;
+  }
+  .md-pl1 {
+    padding-left: 1rem;
+  }
+  .md-pr1-3 {
+    padding-right: 1.3rem;
+  }
+  .md-pl1-3 {
+    padding-left: 1.3rem;
+  }
+  .md-pt3-35 {
+    padding-top: 3.35rem;
+  }
+  .md-pr3-35 {
+    padding-right: 3.35rem;
+  }
+  .md-pb3-35 {
+    padding-bottom: 3.35rem;
+  }
+  .md-pl3-35 {
+    padding-left: 3.35rem;
+  }
+  .md-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .md-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .md-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .md-pl4-2 {
+    padding-left: 4.2rem;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-pb0 {
+    padding-bottom: 0;
+  }
+  .lg-pr2 {
+    padding-right: 2;
+  }
+  .lg-pt0-42 {
+    padding-top: 0.42rem;
+  }
+  .lg-pr0-42 {
+    padding-right: 0.42rem;
+  }
+  .lg-pb0-42 {
+    padding-bottom: 0.42rem;
+  }
+  .lg-pl0-42 {
+    padding-left: 0.42rem;
+  }
+  .lg-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .lg-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .lg-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .lg-pl4-2 {
+    padding-left: 4.2rem;
+  }
+  .lg-pt8-3 {
+    padding-top: 8.3rem;
+  }
+  .lg-pr8-3 {
+    padding-right: 8.3rem;
+  }
+  .lg-pb8-3 {
+    padding-bottom: 8.3rem;
+  }
+  .lg-pl8-3 {
+    padding-left: 8.3rem;
+  }
+}
+.logo-eye {
+  width: 27px;
+  height: 22.7px;
+}
+.logo-hardware {
+  width: 120px;
+  height: 14px;
+}
+@media screen and (min-width: 760px) {
+  .logo-hardware {
+    width: 160px;
+    height: 18px;
+  }
+}
+.bgc-white {
+  background-color: var(--c-white);
+}
+.bgc-gr-black {
+  background:
+    linear-gradient(
+      180deg,
+      var(--c-blackish) 0%,
+      var(--c-dark-black) 100%);
+  background: var(--c-dark-black);
+}
+.bgc-black {
+  background-color: var(--c-black);
+}
+.bgc-gaow {
+  background-color: var(--c-gaow);
+}
+.bgc-bright-black {
+  background-color: var(--c-bright-black);
+}
+.fgc-terre {
+  color: var(--c-terre);
+}
+.fgc-gray {
+  color: var(--c-gray);
+}
+.fgc-dark-gray {
+  color: var(--c-dark-gray);
+}
+.bgc-dark-brown {
+  background-color: var(--c-dark-brown);
+}
+.bgc-dark-bright-brown {
+  background-color: var(--c-dark-bright-brown);
+}
+.fgc-light-gray {
+  color: var(--c-light-gray);
+}
+.fgc-anon-gray {
+  color: var(--c-anon-gray);
+}
+.fgc-green {
+  color: var(--c-green);
+}
+.fgc-light-blue {
+  color: var(--c-light-blue);
+}
+.bgc-dark-blue {
+  background-color: var(--c-dark-blue);
+}
+.bgc-dark-bluegreen {
+  background-color: var(--c-dark-bluegreen);
+}
+.fgc-cyan {
+  color: var(--c-cyan);
+}
+.btn-product-code {
+  padding: 2px 4px 3px 4px;
+}
+.btn-checkout-remove {
+  padding: 5px 7px;
+}
+.btn-submit {
+  padding: 8px 12px 10px 12px;
+}
+.btn-product-soldout {
+  border: 1px solid var(--c-dark-rubin);
+  background-color: var(--c-dark-bright-brown);
+  color: var(--c-light-gray);
+}
+.btn-product-unselectable {
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-black);
+  color: var(--c-light-gray);
+}
+.btn-product-available {
+  cursor: pointer;
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-bluegreen);
+  color: var(--c-cyan);
+}
+.payment-buttons {
+  gap: 1rem;
+}
+.btn-stripe {
+  width: 88px;
+  height: 42px;
+}
+.btn-nowpayments {
+  width: 170px;
+  height: 42px;
+}
+@media screen and (min-width: 540px) {
+  .btn-stripe {
+    width: 116px;
+    height: 55px;
+  }
+  .btn-nowpayments {
+    width: 220px;
+    height: 55px;
+  }
+}
+.product-size-guide h1,
+.product-size-guide h2,
+.product-size-guide h3,
+.product-size-guide h4,
+.product-size-guide h5,
+.product-size-guide h6 {
+  color: var(--c-another-blue);
+  text-transform: uppercase;
+  padding-bottom: 1.7rem;
+}
+.product-size-guide table {
+  width: 100%;
+}
+.copy table {
+  table-layout: fixed;
+  border-collapse: collapse;
+}
+.copy th,
+.copy td {
+  padding: 4px 0;
+  text-transform: uppercase;
+  font-size: 10px;
+}
+.copy td {
+  border-bottom: 1px solid var(--c-forest-green);
+  text-align: center;
+}
+.copy td:first-child {
+  text-align: left;
+  color: var(--c-light-green);
+}
+.related-products {
+  grid-template-columns: repeat(2, 1fr);
+  overflow: hidden;
+  --gap: 4px;
+  --line-offset: calc(var(--gap) / 2);
+  --line-thickness: 1px;
+  --line-color: var(--c-gray);
+}
+@media screen and (min-width: 760px) {
+  .related-products {
+    grid-template-columns: repeat(4, 1fr);
+  }
+}
+@media screen and (min-width: 2400px) {
+  .related-products {
+    grid-template-columns: repeat(5, 1fr);
+  }
+}
+.related-products::after {
+  content: "";
+  background-color: var(--c-gray);
+  width: 1000%;
+  height: 1px;
+  margin-top: auto;
+}
+.related-products-child {
+  border-bottom: 1px solid var(--c-gray);
+  border-right: 1px solid var(--c-gray);
+}
+.copy-checkout-disclaimer {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 20px;
+}
+@media screen and (min-width: 760px) {
+  .copy-checkout-disclaimer {
+    gap: 34px;
+  }
+}
+.sunset {
+  width: 100%;
+  height: 15vh;
+  background-repeat: no-repeat;
+  background-position: bottom;
+}
+.curp {
+  cursor: pointer;
+}
+.cura {
+  cursor: auto;
+}
+.w5r {
+  width: 5rem;
+}
+.w30 {
+  width: 30%;
+}
+.w50 {
+  width: 50%;
+}
+.w70 {
+  width: 70%;
+}
+.w100 {
+  width: 100%;
+}
+.mw-680px {
+  max-width: 680px;
+}
+.h15vh {
+  height: 15vh;
+}
+.h100 {
+  height: 100%;
+}
+@media screen and (min-width: 540px) {
+  .sm-w30 {
+    width: 30%;
+  }
+  .sm-w70 {
+    width: 70%;
+  }
+  .sm-w100 {
+    width: 100%;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-w25 {
+    width: 25%;
+  }
+  .md-w50 {
+    width: 50%;
+  }
+  .md-w100 {
+    width: 100%;
+  }
+  .md-h100 {
+    height: 100%;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-w25 {
+    width: 25%;
+  }
+  .lg-w75 {
+    width: 75%;
+  }
+  .lg-w50 {
+    width: 50%;
+  }
+}
+@media screen and (min-width: 2400px) {
+  .hg-w1200 {
+    width: 1200px;
+  }
+  .hg-x1 {
+    flex: 1;
+  }
+}
+.nav-height {
+  padding-top: 34.7px;
+}
+@media screen and (min-width: 760px) {
+  .nav-height {
+    padding-top: 46.7px;
+  }
+}
+.form-input {
+  padding: 14.5px 10px;
+  background-color: #00120e;
+  border: 1px solid #213f38;
+}
+.icon-p {
+  width: 12px;
+  height: 12px;
+}

+ 1211 - 0
static/dist/styles-K6KOU2HW.css

@@ -0,0 +1,1211 @@
+/* static/css/styles.css */
+@font-face {
+  font-family: "IBM Plex Mono Light";
+  src:
+    url("./fonts/IBMPlexMono-Light.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-Light.woff") format("woff"),
+    font-style: normal;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "IBM Plex Mono Light Italic";
+  src:
+    url("./fonts/IBMPlexMono-LightItalic.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-LightItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold";
+  src:
+    url("./fonts/Inter-Bold.woff2") format("woff2"),
+    url("./fonts/Inter-Bold.woff") format("woff"),
+    font-style: normal;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold Italic";
+  src:
+    url("./fonts/Inter-BoldItalic.woff2") format("woff2"),
+    url("./fonts/Inter-BoldItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Weiss Rund Gotisch";
+  src:
+    url("./fonts/weissrundgotisch.regular.woff2") format("woff2"),
+    url("./fonts/weissrundgotisch.regular.woff") format("woff"),
+    font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+}
+:root {
+  --nav-height: 35px;
+  --c-white: #ffffff;
+  --c-anon-gray: #a5a5a5;
+  --c-light-gray: #dbdbdb;
+  --c-gray: #b7b7b7;
+  --c-pink: #ffbcfc;
+  --c-dark-bright-brown: #241b18;
+  --c-dark-rubin: #3d230b;
+  --c-rubin: #610000;
+  --c-dark-red: #520000;
+  --c-deep-brown: #2B2222;
+  --c-terre: #AA9494;
+  --c-green: #8dff98;
+  --c-forest-green: #4c8042;
+  --c-light-green: #f2ffcd;
+  --c-light-blue: #bffbff;
+  --c-another-blue: #80d1ff;
+  --c-cyan: #00ffff;
+  --c-dark-teal: #005669;
+  --c-bluegreen: #002f10;
+  --c-dark-green: #002C29;
+  --c-dark-bluegreen: #001b1d;
+  --c-blue: #00114c;
+  --c-dark-blue: #00111d;
+  --c-brown: #2f2a00;
+  --c-dark-brown: #1a1d00;
+  --c-gray: #4b4b4b;
+  --c-dark-gray: #555353;
+  --c-bright-black: #020713;
+  --c-gaow: #160E0E;
+  --c-blackish: #11181d;
+  --c-dark-black: #090d10;
+  --c-black: #090909;
+  --c-blackest: #000000;
+  --c-yet-gray: #292929;
+  --f-mono: "IBM Plex Mono Light", monospace;
+  --f-mono-italic: "IBM Plex Mono Light Italic", monospace;
+  --f-sans: "Inter Bold", sans-serif;
+  --f-sans-italic: "Inter Bold Italic", sans-serif;
+  --f-gothic: "Weiss Rund Gotisch", serif;
+}
+@media screen and (min-width: 760px) {
+  :root {
+    --nav-height: 47px;
+  }
+}
+html {
+  box-sizing: border-box;
+  scroll-padding-top: var(--nav-height);
+}
+*,
+*:before,
+*:after {
+  box-sizing: inherit;
+}
+html,
+body {
+  scroll-behavior: smooth;
+}
+html,
+body,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+ol,
+ul,
+li,
+figure {
+  margin: 0;
+  padding: 0;
+}
+figure img {
+  display: block;
+}
+ol,
+ul {
+  padding-left: 1rem;
+}
+img {
+  max-width: 100%;
+  height: auto;
+}
+button {
+  font-family: inherit;
+  font-size: inherit;
+  border: none;
+  border-radius: 0;
+  margin: 0;
+  padding: 0;
+  background: none;
+  color: inherit;
+  cursor: pointer;
+}
+body {
+  -webkit-overflow-scrolling: touch;
+  -ms-overflow-style: none;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  text-rendering: optimizeLegibility;
+  width: 100%;
+  height: 100%;
+}
+html {
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  color: var(--c-white);
+  font-size: 12px;
+  font-family: var(--f-mono);
+}
+a {
+  text-decoration: none;
+  color: inherit;
+}
+input,
+select,
+textarea {
+  background-color: inherit;
+  color: inherit;
+  border: 0;
+  font-family: inherit;
+  font-style: inherit;
+  font-weight: inherit;
+  font-size: inherit;
+  margin: 0;
+  padding: 0;
+}
+input::placeholder,
+textarea::placeholder {
+  opacity: 1;
+  color: #9f879b;
+}
+fieldset {
+  border: 0;
+  padding: 0;
+}
+.fs-small {
+  font-size: 10px;
+}
+.fs-medium {
+  font-size: 1rem;
+}
+.fs-biggish {
+  font-size: 14px;
+}
+.fs-big {
+  font-size: 15px;
+}
+.fs-bigger {
+  font-size: 16px;
+}
+.fs-large {
+  font-size: 18px;
+}
+.fs-larger {
+  font-size: 20px;
+}
+.fs-huge {
+  font-size: 24px;
+}
+.fs-wide {
+  font-size: 30px;
+}
+.ff-sans {
+  font-family: var(--f-sans);
+}
+.ff-sans em,
+.ff-sans italic {
+  font-family: var(--f-sans-italic);
+  font-style: normal;
+}
+.ff-mono em,
+.ff-mono italic {
+  font-family: var(--f-mono-italic);
+  font-style: normal;
+}
+@media screen and (min-width: 540px) {
+  .sm-fs-medium {
+    font-size: 1rem;
+  }
+  .sm-fs-biggish {
+    font-size: 14px;
+  }
+  .sm-fs-big {
+    font-size: 15px;
+  }
+  .sm-fs-large {
+    font-size: 18px;
+  }
+  .sm-fs-larger {
+    font-size: 20px;
+  }
+  .sm-fs-huge {
+    font-size: 24px;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-fs-big {
+    font-size: 15px;
+  }
+}
+.tac {
+  text-align: center;
+}
+.tar {
+  text-align: right;
+}
+@media screen and (min-width: 540px) {
+  .sm-tar {
+    text-align: right;
+  }
+}
+.copy h3,
+.copy h4,
+.copy h5,
+.copy h6,
+.copy p,
+.copy table {
+  max-width: 700px;
+}
+.copy-h-reset-weight h1,
+.copy-h-reset-weight h2,
+.copy-h-reset-weight h3,
+.copy-h-reset-weight h4,
+.copy-h-reset-weight h5,
+.copy-h-reset-weight h6 {
+  font-weight: inherit;
+}
+.copy-h-reset h1,
+.copy-h-reset h2,
+.copy-h-reset h3,
+.copy-h-reset h4,
+.copy-h-reset h5,
+.copy-h-reset h6 {
+  font-size: inherit;
+  font-style: inherit;
+}
+.copy-h h1,
+.copy-h h2 {
+  font-size: 30px;
+  font-family: var(--f-gothic);
+  padding: 15px 20px;
+  border-bottom: 1px solid var(--c-gray);
+}
+@media screen and (min-width: 1920px) {
+  .copy-h h1,
+  .copy-h h2 {
+    padding: 30px 50px;
+  }
+  .copy-h-front h1,
+  .copy-h-front h2,
+  .copy-h-page h1,
+  .copy-h-page h2 {
+    padding: 2.5rem 4.2rem;
+  }
+}
+.copy-h-page h3,
+.copy-h-page h4,
+.copy-h-page h5,
+.copy-h-page h6 {
+  font-size: inherit;
+}
+.copy-page h3,
+.copy-page h4,
+.copy-page h5,
+.copy-page h6,
+.copy-page p {
+  padding: 0.75rem 0.75rem 2.5rem 0.7rem;
+}
+.copy-page table {
+  margin: 0 0.75rem 2.45em 0.7rem;
+}
+.copy-page table td {
+  width: 7.5rem;
+}
+.copy a {
+  color: var(--c-cyan);
+}
+.ttu {
+  text-transform: uppercase;
+}
+.hvc:hover {
+  color: var(--c-green);
+}
+.checkout-image {
+  width: 120px;
+}
+@media screen and (min-width: 540px) {
+  .checkout-image {
+    width: 220px;
+  }
+}
+.img-page img {
+  max-height: 1000px;
+}
+@media screen and (min-width: 2400px) {
+  .img-page img {
+    max-height: 12000px;
+  }
+}
+.bd-white {
+  border: 1px solid var(--c-white);
+}
+.bdb-pink {
+  border-bottom: 1px solid var(--c-pink);
+}
+.bdb-dark-green {
+  border-bottom: 1px solid var(--c-dark-green);
+}
+.bd-dark-rubin {
+  border: 1px solid var(--c-dark-rubin);
+}
+.bdb-dark-red {
+  border-bottom: 1px solid var(--c-dark-red);
+}
+.bd-yet-gray {
+  border: 1px solid var(--c-yet-gray);
+}
+.bd-deep-brown {
+  border: 1px solid var(--c-deep-brown);
+}
+.bdt-gray {
+  border-top: 1px solid var(--c-gray);
+}
+.bdb-gray {
+  border-bottom: 1px solid var(--c-gray);
+}
+.bd-dark-gray {
+  border: 1px solid var(--c-dark-gray);
+}
+.bd-brown {
+  border: 1px solid var(--c-brown);
+}
+.bd-blue {
+  border: 1px solid var(--c-blue);
+}
+.bd-bluegreen {
+  border: 1px solid var(--c-bluegreen);
+}
+@media screen and (min-width: 760px) {
+  .md-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+  .md-bdl-rubin {
+    border-left: 1px solid var(--c-rubin);
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+  .bg-bdl-gray {
+    border-left: 1px solid var(--c-gray);
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+  .lg-bdl-gray {
+    border-left: 1px solid var(--c-gray);
+  }
+  .lg-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+}
+.brad-6 {
+  border-radius: 6px;
+}
+.vh {
+  clip: rect(0 0 0 0);
+  -webkit-clip-path: inset(50%);
+  clip-path: inset(50%);
+  height: 1px;
+  overflow: hidden;
+  position: absolute;
+  white-space: nowrap;
+  width: 1px;
+}
+.dn {
+  display: none;
+}
+.dib {
+  display: inline-block;
+}
+.db {
+  display: block;
+}
+.x {
+  display: flex;
+}
+.xw {
+  flex-wrap: wrap;
+}
+.xdr {
+  flex-direction: row;
+}
+.xdc {
+  flex-direction: column;
+}
+.xaic {
+  align-items: center;
+}
+.xaib {
+  align-items: baseline;
+}
+.xjsb {
+  justify-content: space-between;
+}
+.xo1 {
+  order: 1;
+}
+.xo2 {
+  order: 2;
+}
+.xo3 {
+  order: 3;
+}
+@media screen and (min-width: 400px) {
+  .tn-xdr {
+    flex-direction: row;
+  }
+}
+@media screen and (min-width: 540px) {
+  .sm-xaic {
+    align-items: center;
+  }
+  .sm-xo1 {
+    order: 1;
+  }
+  .sm-xo2 {
+    order: 2;
+  }
+  .sm-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-dn {
+    display: none;
+  }
+  .md-db {
+    display: block;
+  }
+  .md-x {
+    display: flex;
+  }
+  .md-xdc {
+    flex-direction: column;
+  }
+  .md-xdr {
+    flex-direction: row;
+  }
+  .md-xw {
+    flex-wrap: wrap;
+  }
+  .md-xo1 {
+    order: 1;
+  }
+  .md-xo2 {
+    order: 2;
+  }
+  .md-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-xw {
+    flex-wrap: wrap;
+  }
+  .bg-xdr {
+    flex-direction: row;
+  }
+  .bg-xjsb {
+    justify-content: space-between;
+  }
+  .bg-xo1 {
+    order: 1;
+  }
+  .bg-xo2 {
+    order: 2;
+  }
+  .bg-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-x {
+    display: flex;
+  }
+  .lg-xdr {
+    flex-direction: row;
+  }
+  .lg-xw {
+    flex-wrap: wrap;
+  }
+  .lg-xdc {
+    flex-direction: column;
+  }
+  .lg-xo1 {
+    order: 1;
+  }
+  .lg-xo2 {
+    order: 2;
+  }
+  .lg-xo3 {
+    order: 3;
+  }
+  .lg-xjsb {
+    justify-content: space-between;
+  }
+}
+.dg {
+  display: grid;
+}
+.z1 {
+  z-index: 1;
+}
+.z2 {
+  z-index: 2;
+}
+.psr {
+  position: relative;
+}
+.psf {
+  position: fixed;
+}
+.psa {
+  position: absolute;
+}
+.t0 {
+  top: 0;
+}
+.b0 {
+  bottom: 0;
+}
+.l0 {
+  left: 0;
+}
+.t50 {
+  top: 50%;
+}
+.l50 {
+  left: 50%;
+}
+.ttcx {
+  transform: translateX(-50%);
+}
+.ttcc {
+  transform: translate(-50%, -50%);
+}
+.mta {
+  margin-top: auto;
+}
+.mla {
+  margin-left: auto;
+}
+.mra {
+  margin-right: auto;
+}
+.mt1 {
+  margin-top: 1rem;
+}
+.mr1 {
+  margin-right: 1rem;
+}
+.mr0-5 {
+  margin-right: 0.5rem;
+}
+.ml0-5 {
+  margin-left: 0.5rem;
+}
+.ml1 {
+  margin-left: 1rem;
+}
+.mb1-25 {
+  margin-bottom: 1.25rem;
+}
+.mb4-25 {
+  margin-bottom: 4.25rem;
+}
+.pt0-25 {
+  padding-top: 0.25rem;
+}
+.pr0-25 {
+  padding-right: 0.25rem;
+}
+.pb0-25 {
+  padding-bottom: 0.25rem;
+}
+.pl0-25 {
+  padding-left: 0.25rem;
+}
+.pt0-5 {
+  padding-top: 0.5rem;
+}
+.pr0-5 {
+  padding-right: 0.5rem;
+}
+.pb0-5 {
+  padding-bottom: 0.5rem;
+}
+.pl0-5 {
+  padding-left: 0.5rem;
+}
+.pt0-75 {
+  padding-top: 0.75rem;
+}
+.pr0-75 {
+  padding-right: 0.75rem;
+}
+.pb0-75 {
+  padding-bottom: 0.75rem;
+}
+.pl0-75 {
+  padding-left: 0.75rem;
+}
+.pt0-85 {
+  padding-top: 0.85rem;
+}
+.pr0-85 {
+  padding-right: 0.85rem;
+}
+.pb0-85 {
+  padding-bottom: 0.85rem;
+}
+.pl0-85 {
+  padding-left: 0.85rem;
+}
+.pt1 {
+  padding-top: 1rem;
+}
+.pb1 {
+  padding-bottom: 1rem;
+}
+.pb1-25 {
+  padding-bottom: 1.25rem;
+}
+.pt1-7 {
+  padding-top: 1.7rem;
+}
+.pb1-7 {
+  padding-bottom: 1.7rem;
+}
+.pb2 {
+  padding-bottom: 2rem;
+}
+.pt3-5 {
+  padding-top: 3.5rem;
+}
+.pb3 {
+  padding-bottom: 3rem;
+}
+.pt4-25 {
+  padding-top: 4.25rem;
+}
+.pb4-25 {
+  padding-bottom: 4.25rem;
+}
+.pt4-75 {
+  padding-top: 4.75rem;
+}
+.pb4-75 {
+  padding-bottom: 4.75rem;
+}
+.pb5-5 {
+  padding-bottom: 5.5rem;
+}
+.pt7 {
+  padding-top: 7rem;
+}
+.pb7 {
+  padding-bottom: 7rem;
+}
+@media screen and (min-width: 540px) {
+  .sm-pr0 {
+    padding-right: 0;
+  }
+  .sm-pl0 {
+    padding-left: 0;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-ml1-25 {
+    margin-left: 1.25rem;
+  }
+  .md-pt0-5 {
+    padding-top: 0.5rem;
+  }
+  .md-pr0-5 {
+    padding-right: 0.5rem;
+  }
+  .md-pb0-5 {
+    padding-bottom: 0.5rem;
+  }
+  .md-pl0-5 {
+    padding-left: 0.5rem;
+  }
+  .md-pt1 {
+    padding-top: 1rem;
+  }
+  .md-pr1 {
+    padding-right: 1rem;
+  }
+  .md-pb1 {
+    padding-bottom: 1rem;
+  }
+  .md-pl1 {
+    padding-left: 1rem;
+  }
+  .md-pr1-3 {
+    padding-right: 1.3rem;
+  }
+  .md-pl1-3 {
+    padding-left: 1.3rem;
+  }
+  .md-pt3-35 {
+    padding-top: 3.35rem;
+  }
+  .md-pr3-35 {
+    padding-right: 3.35rem;
+  }
+  .md-pb3-35 {
+    padding-bottom: 3.35rem;
+  }
+  .md-pl3-35 {
+    padding-left: 3.35rem;
+  }
+  .md-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .md-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .md-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .md-pl4-2 {
+    padding-left: 4.2rem;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-pb0 {
+    padding-bottom: 0;
+  }
+  .bg-pt1-25 {
+    padding-top: 1.25rem;
+  }
+  .bg-pr1-25 {
+    padding-right: 1.25rem;
+  }
+  .bg-pb1-25 {
+    padding-bottom: 1.25rem;
+  }
+  .bg-pl1-25 {
+    padding-left: 1.25rem;
+  }
+  .bg-pt1-7 {
+    padding-top: 1.7rem;
+  }
+  .bg-pr1-7 {
+    padding-right: 1.7rem;
+  }
+  .bg-pb1-7 {
+    padding-bottom: 1.7rem;
+  }
+  .bg-pl1-7 {
+    padding-left: 1.7rem;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .bg-pr8-3 {
+    padding-right: 8.3rem;
+  }
+  .bg-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .bg-pl4-2 {
+    padding-left: 4.2rem;
+  }
+  .bg-pl8-3 {
+    padding-left: 8.3rem;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-pb0 {
+    padding-bottom: 0;
+  }
+  .lg-pr2 {
+    padding-right: 2;
+  }
+  .lg-pt0-42 {
+    padding-top: 0.42rem;
+  }
+  .lg-pr0-42 {
+    padding-right: 0.42rem;
+  }
+  .lg-pb0-42 {
+    padding-bottom: 0.42rem;
+  }
+  .lg-pl0-42 {
+    padding-left: 0.42rem;
+  }
+  .lg-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .lg-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .lg-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .lg-pl4-2 {
+    padding-left: 4.2rem;
+  }
+  .lg-pt8-3 {
+    padding-top: 8.3rem;
+  }
+  .lg-pr8-3 {
+    padding-right: 8.3rem;
+  }
+  .lg-pb8-3 {
+    padding-bottom: 8.3rem;
+  }
+  .lg-pl8-3 {
+    padding-left: 8.3rem;
+  }
+}
+.logo-eye {
+  width: 27px;
+  height: 22.7px;
+}
+.logo-hardware {
+  width: 120px;
+  height: 14px;
+}
+@media screen and (min-width: 760px) {
+  .logo-hardware {
+    width: 160px;
+    height: 18px;
+  }
+}
+.bgc-white {
+  background-color: var(--c-white);
+}
+.bgc-gr-black {
+  background:
+    linear-gradient(
+      180deg,
+      var(--c-blackish) 0%,
+      var(--c-dark-black) 100%);
+  background: var(--c-dark-black);
+}
+.bgc-black {
+  background-color: var(--c-black);
+}
+.bgc-blackest {
+  background-color: var(--c-blackest);
+}
+.bgc-gaow {
+  background-color: var(--c-gaow);
+}
+.bgc-bright-black {
+  background-color: var(--c-bright-black);
+}
+.fgc-terre {
+  color: var(--c-terre);
+}
+.fgc-gray {
+  color: var(--c-gray);
+}
+.fgc-dark-gray {
+  color: var(--c-dark-gray);
+}
+.bgc-dark-brown {
+  background-color: var(--c-dark-brown);
+}
+.bgc-dark-bright-brown {
+  background-color: var(--c-dark-bright-brown);
+}
+.fgc-light-gray {
+  color: var(--c-light-gray);
+}
+.fgc-anon-gray {
+  color: var(--c-anon-gray);
+}
+.fgc-green {
+  color: var(--c-green);
+}
+.fgc-light-blue {
+  color: var(--c-light-blue);
+}
+.bgc-dark-blue {
+  background-color: var(--c-dark-blue);
+}
+.bgc-dark-bluegreen {
+  background-color: var(--c-dark-bluegreen);
+}
+.fgc-cyan {
+  color: var(--c-cyan);
+}
+.btn-product-code {
+  padding: 2px 4px 3px 4px;
+}
+.btn-checkout-remove {
+  padding: 5px 7px;
+}
+.btn-submit {
+  padding: 8px 12px 10px 12px;
+}
+.btn-product-soldout {
+  border: 1px solid var(--c-dark-rubin);
+  background-color: var(--c-dark-bright-brown);
+  color: var(--c-light-gray);
+}
+.btn-product-unselectable {
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-black);
+  color: var(--c-light-gray);
+}
+.btn-product-available {
+  cursor: pointer;
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-bluegreen);
+  color: var(--c-cyan);
+}
+.btn-product-available:hover {
+  cursor: pointer;
+  border: 1px solid var(--c-dark-teal);
+  background-color: var(--c-blackish);
+  color: var(--c-another-blue);
+}
+.payment-buttons {
+  gap: 1rem;
+}
+.btn-stripe {
+  width: 88px;
+  height: 42px;
+}
+.btn-nowpayments {
+  width: 170px;
+  height: 42px;
+}
+@media screen and (min-width: 540px) {
+  .btn-stripe {
+    width: 116px;
+    height: 55px;
+  }
+  .btn-nowpayments {
+    width: 220px;
+    height: 55px;
+  }
+}
+.product-size-guide h1,
+.product-size-guide h2,
+.product-size-guide h3,
+.product-size-guide h4,
+.product-size-guide h5,
+.product-size-guide h6 {
+  color: var(--c-another-blue);
+  text-transform: uppercase;
+  padding-bottom: 1.7rem;
+}
+.product-size-guide table {
+  width: 100%;
+}
+.copy table {
+  table-layout: fixed;
+  border-collapse: collapse;
+}
+.copy th,
+.copy td {
+  padding: 4px 0;
+  text-transform: uppercase;
+  font-size: 10px;
+}
+.copy td {
+  border-bottom: 1px solid var(--c-forest-green);
+  text-align: center;
+}
+.copy td:first-child {
+  text-align: left;
+  color: var(--c-light-green);
+}
+.related-products {
+  grid-template-columns: repeat(2, 1fr);
+  overflow: hidden;
+  --gap: 4px;
+  --line-offset: calc(var(--gap) / 2);
+  --line-thickness: 1px;
+  --line-color: var(--c-gray);
+}
+@media screen and (min-width: 760px) {
+  .related-products {
+    grid-template-columns: repeat(4, 1fr);
+  }
+}
+@media screen and (min-width: 2400px) {
+  .related-products {
+    grid-template-columns: repeat(5, 1fr);
+  }
+}
+.related-products::after {
+  content: "";
+  background-color: var(--c-gray);
+  width: 1000%;
+  height: 1px;
+  margin-top: auto;
+}
+.related-products-child {
+  border-bottom: 1px solid var(--c-gray);
+  border-right: 1px solid var(--c-gray);
+}
+.related-products a:hover {
+  color: var(--c-cyan);
+  border-right: 1px solid var(--c-gray);
+}
+.cart-icon:hover {
+  color: var(--c-cyan);
+}
+.fgc-cyan:hover {
+  color: var(--c-pink);
+}
+.copy-checkout-disclaimer {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 20px;
+}
+@media screen and (min-width: 760px) {
+  .copy-checkout-disclaimer {
+    gap: 34px;
+  }
+}
+.sunset {
+  width: 100%;
+  height: 15vh;
+  background-repeat: no-repeat;
+  background-position: bottom;
+}
+.curp {
+  cursor: pointer;
+}
+.cura {
+  cursor: auto;
+}
+.w5r {
+  width: 5rem;
+}
+.w30 {
+  width: 30%;
+}
+.w50 {
+  width: 50%;
+}
+.w70 {
+  width: 70%;
+}
+.w100 {
+  width: 100%;
+}
+.mw-680px {
+  max-width: 680px;
+}
+.h15vh {
+  height: 15vh;
+}
+.h100 {
+  height: 100%;
+}
+@media screen and (min-width: 540px) {
+  .sm-w30 {
+    width: 30%;
+  }
+  .sm-w70 {
+    width: 70%;
+  }
+  .sm-w100 {
+    width: 100%;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-w25 {
+    width: 25%;
+  }
+  .md-w30 {
+    width: 30%;
+  }
+  .md-w50 {
+    width: 50%;
+  }
+  .md-w70 {
+    width: 70%;
+  }
+  .md-w100 {
+    width: 100%;
+  }
+  .md-h100 {
+    height: 100%;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-w25 {
+    width: 25%;
+  }
+  .bg-w50 {
+    width: 50%;
+  }
+  .bg-w75 {
+    width: 75%;
+  }
+  .bg-w100 {
+    width: 100%;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-w25 {
+    width: 25%;
+  }
+  .lg-w75 {
+    width: 75%;
+  }
+  .lg-w50 {
+    width: 50%;
+  }
+}
+@media screen and (min-width: 2400px) {
+  .hg-w1200 {
+    width: 1200px;
+  }
+  .hg-x1 {
+    flex: 1;
+  }
+}
+.nav-height {
+  padding-top: 34.7px;
+}
+@media screen and (min-width: 760px) {
+  .nav-height {
+    padding-top: 46.7px;
+  }
+}
+.form-input {
+  padding: 14.5px 10px;
+  background-color: #00120e;
+  border: 1px solid #213f38;
+}
+@media screen and (min-width: 760px) {
+  .sticky-prod-info {
+    position: -webkit-sticky;
+    position: sticky;
+    top: 48px;
+  }
+}
+.icon-p {
+  width: 12px;
+  height: 12px;
+}

+ 1122 - 0
static/dist/styles-SUK2W4LI.css

@@ -0,0 +1,1122 @@
+/* static/css/styles.css */
+@font-face {
+  font-family: "IBM Plex Mono Light";
+  src:
+    url("./fonts/IBMPlexMono-Light.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-Light.woff") format("woff"),
+    font-style: normal;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "IBM Plex Mono Light Italic";
+  src:
+    url("./fonts/IBMPlexMono-LightItalic.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-LightItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold";
+  src:
+    url("./fonts/Inter-Bold.woff2") format("woff2"),
+    url("./fonts/Inter-Bold.woff") format("woff"),
+    font-style: normal;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold Italic";
+  src:
+    url("./fonts/Inter-BoldItalic.woff2") format("woff2"),
+    url("./fonts/Inter-BoldItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Weiss Rund Gotisch";
+  src:
+    url("./fonts/weissrundgotisch.regular.woff2") format("woff2"),
+    url("./fonts/weissrundgotisch.regular.woff") format("woff"),
+    font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+}
+:root {
+  --nav-height: 35px;
+  --c-white: #ffffff;
+  --c-anon-gray: #a5a5a5;
+  --c-light-gray: #dbdbdb;
+  --c-gray: #b7b7b7;
+  --c-pink: #ffbcfc;
+  --c-dark-bright-brown: #241b18;
+  --c-dark-rubin: #3d230b;
+  --c-rubin: #610000;
+  --c-dark-red: #520000;
+  --c-deep-brown: #2B2222;
+  --c-terre: #AA9494;
+  --c-green: #8dff98;
+  --c-forest-green: #4c8042;
+  --c-light-green: #f2ffcd;
+  --c-light-blue: #bffbff;
+  --c-another-blue: #80d1ff;
+  --c-cyan: #00ffff;
+  --c-bluegreen: #002f10;
+  --c-dark-green: #002C29;
+  --c-dark-bluegreen: #001b1d;
+  --c-blue: #00114c;
+  --c-dark-blue: #00111d;
+  --c-brown: #2f2a00;
+  --c-dark-brown: #1a1d00;
+  --c-gray: #4b4b4b;
+  --c-dark-gray: #555353;
+  --c-bright-black: #020713;
+  --c-gaow: #160E0E;
+  --c-blackish: #11181d;
+  --c-dark-black: #090d10;
+  --c-black: #090909;
+  --c-yet-gray: #292929;
+  --f-mono: "IBM Plex Mono Light", monospace;
+  --f-mono-italic: "IBM Plex Mono Light Italic", monospace;
+  --f-sans: "Inter Bold", sans-serif;
+  --f-sans-italic: "Inter Bold Italic", sans-serif;
+  --f-gothic: "Weiss Rund Gotisch", serif;
+}
+@media screen and (min-width: 760px) {
+  :root {
+    --nav-height: 47px;
+  }
+}
+html {
+  box-sizing: border-box;
+  scroll-padding-top: var(--nav-height);
+}
+*,
+*:before,
+*:after {
+  box-sizing: inherit;
+}
+html,
+body {
+  scroll-behavior: smooth;
+}
+html,
+body,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+ol,
+ul,
+li,
+figure {
+  margin: 0;
+  padding: 0;
+}
+figure img {
+  display: block;
+}
+ol,
+ul {
+  padding-left: 1rem;
+}
+img {
+  max-width: 100%;
+  height: auto;
+}
+button {
+  font-family: inherit;
+  font-size: inherit;
+  border: none;
+  border-radius: 0;
+  margin: 0;
+  padding: 0;
+  background: none;
+  color: inherit;
+  cursor: pointer;
+}
+body {
+  -webkit-overflow-scrolling: touch;
+  -ms-overflow-style: none;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  text-rendering: optimizeLegibility;
+  width: 100%;
+  height: 100%;
+}
+html {
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  color: var(--c-white);
+  font-size: 12px;
+  font-family: var(--f-mono);
+}
+a {
+  text-decoration: none;
+  color: inherit;
+}
+input,
+select,
+textarea {
+  background-color: inherit;
+  color: inherit;
+  border: 0;
+  font-family: inherit;
+  font-style: inherit;
+  font-weight: inherit;
+  font-size: inherit;
+  margin: 0;
+  padding: 0;
+}
+input::placeholder,
+textarea::placeholder {
+  opacity: 1;
+  color: #9f879b;
+}
+fieldset {
+  border: 0;
+  padding: 0;
+}
+.fs-small {
+  font-size: 10px;
+}
+.fs-medium {
+  font-size: 1rem;
+}
+.fs-biggish {
+  font-size: 14px;
+}
+.fs-big {
+  font-size: 15px;
+}
+.fs-bigger {
+  font-size: 16px;
+}
+.fs-large {
+  font-size: 18px;
+}
+.fs-larger {
+  font-size: 20px;
+}
+.fs-huge {
+  font-size: 24px;
+}
+.fs-wide {
+  font-size: 30px;
+}
+.ff-sans {
+  font-family: var(--f-sans);
+}
+.ff-sans em,
+.ff-sans italic {
+  font-family: var(--f-sans-italic);
+  font-style: normal;
+}
+.ff-mono em,
+.ff-mono italic {
+  font-family: var(--f-mono-italic);
+  font-style: normal;
+}
+@media screen and (min-width: 540px) {
+  .sm-fs-medium {
+    font-size: 1rem;
+  }
+  .sm-fs-biggish {
+    font-size: 14px;
+  }
+  .sm-fs-big {
+    font-size: 15px;
+  }
+  .sm-fs-large {
+    font-size: 18px;
+  }
+  .sm-fs-larger {
+    font-size: 20px;
+  }
+  .sm-fs-huge {
+    font-size: 24px;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-fs-big {
+    font-size: 15px;
+  }
+}
+.tac {
+  text-align: center;
+}
+.tar {
+  text-align: right;
+}
+@media screen and (min-width: 540px) {
+  .sm-tar {
+    text-align: right;
+  }
+}
+.copy h3,
+.copy h4,
+.copy h5,
+.copy h6,
+.copy p,
+.copy table {
+  max-width: 700px;
+}
+.copy-h-reset-weight h1,
+.copy-h-reset-weight h2,
+.copy-h-reset-weight h3,
+.copy-h-reset-weight h4,
+.copy-h-reset-weight h5,
+.copy-h-reset-weight h6 {
+  font-weight: inherit;
+}
+.copy-h-reset h1,
+.copy-h-reset h2,
+.copy-h-reset h3,
+.copy-h-reset h4,
+.copy-h-reset h5,
+.copy-h-reset h6 {
+  font-size: inherit;
+  font-style: inherit;
+}
+.copy-h h1,
+.copy-h h2 {
+  font-size: 30px;
+  font-family: var(--f-gothic);
+  padding: 15px 20px;
+  border-bottom: 1px solid var(--c-gray);
+}
+@media screen and (min-width: 1920px) {
+  .copy-h-front h1,
+  .copy-h-front h2,
+  .copy-h-page h1,
+  .copy-h-page h2 {
+    padding: 2.5rem 4.2rem;
+  }
+}
+.copy-h-page h3,
+.copy-h-page h4,
+.copy-h-page h5,
+.copy-h-page h6 {
+  font-size: inherit;
+}
+.copy-page h3,
+.copy-page h4,
+.copy-page h5,
+.copy-page h6,
+.copy-page p {
+  padding: 0.75rem 0.75rem 2.5rem 0.7rem;
+}
+.copy-page table {
+  margin: 0 0.75rem 2.45em 0.7rem;
+}
+.copy-page table td {
+  width: 7.5rem;
+}
+.copy a {
+  color: var(--c-cyan);
+}
+.ttu {
+  text-transform: uppercase;
+}
+.hvc:hover {
+  color: var(--c-green);
+}
+.checkout-image {
+  width: 120px;
+}
+@media screen and (min-width: 540px) {
+  .checkout-image {
+    width: 220px;
+  }
+}
+.img-page img {
+  max-height: 1000px;
+}
+@media screen and (min-width: 2400px) {
+  .img-page img {
+    max-height: 12000px;
+  }
+}
+.bd-white {
+  border: 1px solid var(--c-white);
+}
+.bdb-pink {
+  border-bottom: 1px solid var(--c-pink);
+}
+.bdb-dark-green {
+  border-bottom: 1px solid var(--c-dark-green);
+}
+.bd-dark-rubin {
+  border: 1px solid var(--c-dark-rubin);
+}
+.bdb-dark-red {
+  border-bottom: 1px solid var(--c-dark-red);
+}
+.bd-yet-gray {
+  border: 1px solid var(--c-yet-gray);
+}
+.bd-deep-brown {
+  border: 1px solid var(--c-deep-brown);
+}
+.bdt-gray {
+  border-top: 1px solid var(--c-gray);
+}
+.bdb-gray {
+  border-bottom: 1px solid var(--c-gray);
+}
+.bd-dark-gray {
+  border: 1px solid var(--c-dark-gray);
+}
+.bd-brown {
+  border: 1px solid var(--c-brown);
+}
+.bd-blue {
+  border: 1px solid var(--c-blue);
+}
+.bd-bluegreen {
+  border: 1px solid var(--c-bluegreen);
+}
+@media screen and (min-width: 760px) {
+  .md-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+  .md-bdl-rubin {
+    border-left: 1px solid var(--c-rubin);
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+  .lg-bdl-gray {
+    border-left: 1px solid var(--c-gray);
+  }
+  .lg-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+}
+.brad-6 {
+  border-radius: 6px;
+}
+.vh {
+  clip: rect(0 0 0 0);
+  -webkit-clip-path: inset(50%);
+  clip-path: inset(50%);
+  height: 1px;
+  overflow: hidden;
+  position: absolute;
+  white-space: nowrap;
+  width: 1px;
+}
+.dn {
+  display: none;
+}
+.dib {
+  display: inline-block;
+}
+.db {
+  display: block;
+}
+.x {
+  display: flex;
+}
+.xw {
+  flex-wrap: wrap;
+}
+.xdr {
+  flex-direction: row;
+}
+.xdc {
+  flex-direction: column;
+}
+.xaic {
+  align-items: center;
+}
+.xaib {
+  align-items: baseline;
+}
+.xjsb {
+  justify-content: space-between;
+}
+.xo1 {
+  order: 1;
+}
+.xo2 {
+  order: 2;
+}
+.xo3 {
+  order: 3;
+}
+@media screen and (min-width: 400px) {
+  .tn-xdr {
+    flex-direction: row;
+  }
+}
+@media screen and (min-width: 540px) {
+  .sm-xaic {
+    align-items: center;
+  }
+  .sm-xo1 {
+    order: 1;
+  }
+  .sm-xo2 {
+    order: 2;
+  }
+  .sm-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-dn {
+    display: none;
+  }
+  .md-db {
+    display: block;
+  }
+  .md-x {
+    display: flex;
+  }
+  .md-xdc {
+    flex-direction: column;
+  }
+  .md-xdr {
+    flex-direction: row;
+  }
+  .md-xw {
+    flex-wrap: wrap;
+  }
+  .md-xo1 {
+    order: 1;
+  }
+  .md-xo2 {
+    order: 2;
+  }
+  .md-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-xdr {
+    flex-direction: row;
+  }
+  .bg-xo1 {
+    order: 1;
+  }
+  .bg-xo2 {
+    order: 2;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-x {
+    display: flex;
+  }
+  .lg-xdr {
+    flex-direction: row;
+  }
+  .lg-xw {
+    flex-wrap: wrap;
+  }
+  .lg-xdc {
+    flex-direction: column;
+  }
+  .lg-xo1 {
+    order: 1;
+  }
+  .lg-xo2 {
+    order: 2;
+  }
+  .lg-xo3 {
+    order: 3;
+  }
+  .lg-xjsb {
+    justify-content: space-between;
+  }
+}
+.dg {
+  display: grid;
+}
+.z1 {
+  z-index: 1;
+}
+.z2 {
+  z-index: 2;
+}
+.psr {
+  position: relative;
+}
+.psf {
+  position: fixed;
+}
+.psa {
+  position: absolute;
+}
+.t0 {
+  top: 0;
+}
+.b0 {
+  bottom: 0;
+}
+.l0 {
+  left: 0;
+}
+.t50 {
+  top: 50%;
+}
+.l50 {
+  left: 50%;
+}
+.ttcx {
+  transform: translateX(-50%);
+}
+.ttcc {
+  transform: translate(-50%, -50%);
+}
+.mta {
+  margin-top: auto;
+}
+.mla {
+  margin-left: auto;
+}
+.mra {
+  margin-right: auto;
+}
+.mt1 {
+  margin-top: 1rem;
+}
+.mr1 {
+  margin-right: 1rem;
+}
+.mr0-5 {
+  margin-right: 0.5rem;
+}
+.ml0-5 {
+  margin-left: 0.5rem;
+}
+.ml1 {
+  margin-left: 1rem;
+}
+.mb1-25 {
+  margin-bottom: 1.25rem;
+}
+.mb4-25 {
+  margin-bottom: 4.25rem;
+}
+.pt0-25 {
+  padding-top: 0.25rem;
+}
+.pr0-25 {
+  padding-right: 0.25rem;
+}
+.pb0-25 {
+  padding-bottom: 0.25rem;
+}
+.pl0-25 {
+  padding-left: 0.25rem;
+}
+.pt0-5 {
+  padding-top: 0.5rem;
+}
+.pr0-5 {
+  padding-right: 0.5rem;
+}
+.pb0-5 {
+  padding-bottom: 0.5rem;
+}
+.pl0-5 {
+  padding-left: 0.5rem;
+}
+.pt0-75 {
+  padding-top: 0.75rem;
+}
+.pr0-75 {
+  padding-right: 0.75rem;
+}
+.pb0-75 {
+  padding-bottom: 0.75rem;
+}
+.pl0-75 {
+  padding-left: 0.75rem;
+}
+.pt0-85 {
+  padding-top: 0.85rem;
+}
+.pr0-85 {
+  padding-right: 0.85rem;
+}
+.pb0-85 {
+  padding-bottom: 0.85rem;
+}
+.pl0-85 {
+  padding-left: 0.85rem;
+}
+.pt1 {
+  padding-top: 1rem;
+}
+.pb1 {
+  padding-bottom: 1rem;
+}
+.pb1-25 {
+  padding-bottom: 1.25rem;
+}
+.pt1-7 {
+  padding-top: 1.7rem;
+}
+.pb1-7 {
+  padding-bottom: 1.7rem;
+}
+.pb2 {
+  padding-bottom: 2rem;
+}
+.pt3-5 {
+  padding-top: 3.5rem;
+}
+.pb3 {
+  padding-bottom: 3rem;
+}
+.pt4-25 {
+  padding-top: 4.25rem;
+}
+.pb4-25 {
+  padding-bottom: 4.25rem;
+}
+.pt4-75 {
+  padding-top: 4.75rem;
+}
+.pb4-75 {
+  padding-bottom: 4.75rem;
+}
+.pb5-5 {
+  padding-bottom: 5.5rem;
+}
+.pt7 {
+  padding-top: 7rem;
+}
+.pb7 {
+  padding-bottom: 7rem;
+}
+@media screen and (min-width: 540px) {
+  .sm-pr0 {
+    padding-right: 0;
+  }
+  .sm-pl0 {
+    padding-left: 0;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-ml1-25 {
+    margin-left: 1.25rem;
+  }
+  .md-pt0-5 {
+    padding-top: 0.5rem;
+  }
+  .md-pr0-5 {
+    padding-right: 0.5rem;
+  }
+  .md-pb0-5 {
+    padding-bottom: 0.5rem;
+  }
+  .md-pl0-5 {
+    padding-left: 0.5rem;
+  }
+  .md-pt1 {
+    padding-top: 1rem;
+  }
+  .md-pr1 {
+    padding-right: 1rem;
+  }
+  .md-pb1 {
+    padding-bottom: 1rem;
+  }
+  .md-pl1 {
+    padding-left: 1rem;
+  }
+  .md-pr1-3 {
+    padding-right: 1.3rem;
+  }
+  .md-pl1-3 {
+    padding-left: 1.3rem;
+  }
+  .md-pt3-35 {
+    padding-top: 3.35rem;
+  }
+  .md-pr3-35 {
+    padding-right: 3.35rem;
+  }
+  .md-pb3-35 {
+    padding-bottom: 3.35rem;
+  }
+  .md-pl3-35 {
+    padding-left: 3.35rem;
+  }
+  .md-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .md-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .md-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .md-pl4-2 {
+    padding-left: 4.2rem;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-pb0 {
+    padding-bottom: 0;
+  }
+  .lg-pr2 {
+    padding-right: 2;
+  }
+  .lg-pt0-42 {
+    padding-top: 0.42rem;
+  }
+  .lg-pr0-42 {
+    padding-right: 0.42rem;
+  }
+  .lg-pb0-42 {
+    padding-bottom: 0.42rem;
+  }
+  .lg-pl0-42 {
+    padding-left: 0.42rem;
+  }
+  .lg-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .lg-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .lg-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .lg-pl4-2 {
+    padding-left: 4.2rem;
+  }
+  .lg-pt8-3 {
+    padding-top: 8.3rem;
+  }
+  .lg-pr8-3 {
+    padding-right: 8.3rem;
+  }
+  .lg-pb8-3 {
+    padding-bottom: 8.3rem;
+  }
+  .lg-pl8-3 {
+    padding-left: 8.3rem;
+  }
+}
+.logo-eye {
+  width: 27px;
+  height: 22.7px;
+}
+.logo-hardware {
+  width: 120px;
+  height: 14px;
+}
+@media screen and (min-width: 760px) {
+  .logo-hardware {
+    width: 160px;
+    height: 18px;
+  }
+}
+.bgc-white {
+  background-color: var(--c-white);
+}
+.bgc-gr-black {
+  background:
+    linear-gradient(
+      180deg,
+      var(--c-blackish) 0%,
+      var(--c-dark-black) 100%);
+  background: var(--c-dark-black);
+}
+.bgc-black {
+  background-color: var(--c-black);
+}
+.bgc-gaow {
+  background-color: var(--c-gaow);
+}
+.bgc-bright-black {
+  background-color: var(--c-bright-black);
+}
+.fgc-terre {
+  color: var(--c-terre);
+}
+.fgc-gray {
+  color: var(--c-gray);
+}
+.fgc-dark-gray {
+  color: var(--c-dark-gray);
+}
+.bgc-dark-brown {
+  background-color: var(--c-dark-brown);
+}
+.bgc-dark-bright-brown {
+  background-color: var(--c-dark-bright-brown);
+}
+.fgc-light-gray {
+  color: var(--c-light-gray);
+}
+.fgc-anon-gray {
+  color: var(--c-anon-gray);
+}
+.fgc-green {
+  color: var(--c-green);
+}
+.fgc-light-blue {
+  color: var(--c-light-blue);
+}
+.bgc-dark-blue {
+  background-color: var(--c-dark-blue);
+}
+.bgc-dark-bluegreen {
+  background-color: var(--c-dark-bluegreen);
+}
+.fgc-cyan {
+  color: var(--c-cyan);
+}
+.btn-product-code {
+  padding: 2px 4px 3px 4px;
+}
+.btn-checkout-remove {
+  padding: 5px 7px;
+}
+.btn-submit {
+  padding: 8px 12px 10px 12px;
+}
+.btn-product-soldout {
+  border: 1px solid var(--c-dark-rubin);
+  background-color: var(--c-dark-bright-brown);
+  color: var(--c-light-gray);
+}
+.btn-product-unselectable {
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-black);
+  color: var(--c-light-gray);
+}
+.btn-product-available {
+  cursor: pointer;
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-bluegreen);
+  color: var(--c-cyan);
+}
+.payment-buttons {
+  gap: 1rem;
+}
+.btn-stripe {
+  width: 88px;
+  height: 42px;
+}
+.btn-nowpayments {
+  width: 170px;
+  height: 42px;
+}
+@media screen and (min-width: 540px) {
+  .btn-stripe {
+    width: 116px;
+    height: 55px;
+  }
+  .btn-nowpayments {
+    width: 220px;
+    height: 55px;
+  }
+}
+.product-size-guide h1,
+.product-size-guide h2,
+.product-size-guide h3,
+.product-size-guide h4,
+.product-size-guide h5,
+.product-size-guide h6 {
+  color: var(--c-another-blue);
+  text-transform: uppercase;
+  padding-bottom: 1.7rem;
+}
+.product-size-guide table {
+  width: 100%;
+}
+.copy table {
+  table-layout: fixed;
+  border-collapse: collapse;
+}
+.copy th,
+.copy td {
+  padding: 4px 0;
+  text-transform: uppercase;
+  font-size: 10px;
+}
+.copy td {
+  border-bottom: 1px solid var(--c-forest-green);
+  text-align: center;
+}
+.copy td:first-child {
+  text-align: left;
+  color: var(--c-light-green);
+}
+.related-products {
+  grid-template-columns: repeat(2, 1fr);
+  overflow: hidden;
+  --gap: 4px;
+  --line-offset: calc(var(--gap) / 2);
+  --line-thickness: 1px;
+  --line-color: var(--c-gray);
+}
+@media screen and (min-width: 760px) {
+  .related-products {
+    grid-template-columns: repeat(4, 1fr);
+  }
+}
+@media screen and (min-width: 2400px) {
+  .related-products {
+    grid-template-columns: repeat(5, 1fr);
+  }
+}
+.related-products::after {
+  content: "";
+  background-color: var(--c-gray);
+  width: 1000%;
+  height: 1px;
+  margin-top: auto;
+}
+.related-products-child {
+  border-bottom: 1px solid var(--c-gray);
+  border-right: 1px solid var(--c-gray);
+}
+.copy-checkout-disclaimer {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 20px;
+}
+@media screen and (min-width: 760px) {
+  .copy-checkout-disclaimer {
+    gap: 34px;
+  }
+}
+.sunset {
+  width: 100%;
+  height: 15vh;
+  background-repeat: no-repeat;
+  background-position: bottom;
+}
+.curp {
+  cursor: pointer;
+}
+.cura {
+  cursor: auto;
+}
+.w5r {
+  width: 5rem;
+}
+.w30 {
+  width: 30%;
+}
+.w50 {
+  width: 50%;
+}
+.w70 {
+  width: 70%;
+}
+.w100 {
+  width: 100%;
+}
+.mw-680px {
+  max-width: 680px;
+}
+.h15vh {
+  height: 15vh;
+}
+.h100 {
+  height: 100%;
+}
+@media screen and (min-width: 540px) {
+  .sm-w30 {
+    width: 30%;
+  }
+  .sm-w70 {
+    width: 70%;
+  }
+  .sm-w100 {
+    width: 100%;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-w25 {
+    width: 25%;
+  }
+  .md-w30 {
+    width: 30%;
+  }
+  .md-w50 {
+    width: 50%;
+  }
+  .md-w70 {
+    width: 70%;
+  }
+  .md-w100 {
+    width: 100%;
+  }
+  .md-h100 {
+    height: 100%;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-w25 {
+    width: 25%;
+  }
+  .bg-w75 {
+    width: 75%;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-w25 {
+    width: 25%;
+  }
+  .lg-w75 {
+    width: 75%;
+  }
+  .lg-w50 {
+    width: 50%;
+  }
+}
+@media screen and (min-width: 2400px) {
+  .hg-w1200 {
+    width: 1200px;
+  }
+  .hg-x1 {
+    flex: 1;
+  }
+}
+.nav-height {
+  padding-top: 34.7px;
+}
+@media screen and (min-width: 760px) {
+  .nav-height {
+    padding-top: 46.7px;
+  }
+}
+.form-input {
+  padding: 14.5px 10px;
+  background-color: #00120e;
+  border: 1px solid #213f38;
+}
+@media screen and (min-width: 760px) {
+  .sticky-prod-info {
+    position: -webkit-sticky;
+    position: sticky;
+    top: 48px;
+  }
+}
+.icon-p {
+  width: 12px;
+  height: 12px;
+}

+ 1225 - 0
static/dist/styles-XHZV2ERZ.css

@@ -0,0 +1,1225 @@
+/* static/css/styles.css */
+@font-face {
+  font-family: "IBM Plex Mono Light";
+  src:
+    url("./fonts/IBMPlexMono-Light.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-Light.woff") format("woff"),
+    font-style: normal;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "IBM Plex Mono Light Italic";
+  src:
+    url("./fonts/IBMPlexMono-LightItalic.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-LightItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold";
+  src:
+    url("./fonts/Inter-Bold.woff2") format("woff2"),
+    url("./fonts/Inter-Bold.woff") format("woff"),
+    font-style: normal;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold Italic";
+  src:
+    url("./fonts/Inter-BoldItalic.woff2") format("woff2"),
+    url("./fonts/Inter-BoldItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Weiss Rund Gotisch";
+  src:
+    url("./fonts/weissrundgotisch.regular.woff2") format("woff2"),
+    url("./fonts/weissrundgotisch.regular.woff") format("woff"),
+    font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+}
+:root {
+  --nav-height: 35px;
+  --c-white: #ffffff;
+  --c-anon-gray: #a5a5a5;
+  --c-light-gray: #dbdbdb;
+  --c-gray: #b7b7b7;
+  --c-pink: #ffbcfc;
+  --c-dark-bright-brown: #241b18;
+  --c-dark-rubin: #3d230b;
+  --c-rubin: #610000;
+  --c-dark-red: #520000;
+  --c-deep-brown: #2B2222;
+  --c-terre: #AA9494;
+  --c-green: #8dff98;
+  --c-forest-green: #4c8042;
+  --c-light-green: #f2ffcd;
+  --c-light-blue: #bffbff;
+  --c-another-blue: #80d1ff;
+  --c-cyan: #00ffff;
+  --c-dark-teal: #005669;
+  --c-bluegreen: #002f10;
+  --c-dark-green: #002C29;
+  --c-dark-bluegreen: #001b1d;
+  --c-blue: #00114c;
+  --c-dark-blue: #00111d;
+  --c-brown: #2f2a00;
+  --c-dark-brown: #1a1d00;
+  --c-gray: #4b4b4b;
+  --c-dark-gray: #555353;
+  --c-bright-black: #020713;
+  --c-gaow: #160E0E;
+  --c-blackish: #11181d;
+  --c-dark-black: #090d10;
+  --c-black: #090909;
+  --c-blackest: #000000;
+  --c-yet-gray: #292929;
+  --f-mono: "IBM Plex Mono Light", monospace;
+  --f-mono-italic: "IBM Plex Mono Light Italic", monospace;
+  --f-sans: "Inter Bold", sans-serif;
+  --f-sans-italic: "Inter Bold Italic", sans-serif;
+  --f-gothic: "Weiss Rund Gotisch", serif;
+}
+@media screen and (min-width: 760px) {
+  :root {
+    --nav-height: 47px;
+  }
+}
+html {
+  box-sizing: border-box;
+  scroll-padding-top: var(--nav-height);
+}
+*,
+*:before,
+*:after {
+  box-sizing: inherit;
+}
+html,
+body {
+  scroll-behavior: smooth;
+}
+html,
+body,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+ol,
+ul,
+li,
+figure {
+  margin: 0;
+  padding: 0;
+}
+figure img {
+  display: block;
+}
+ol,
+ul {
+  padding-left: 1rem;
+}
+img {
+  max-width: 100%;
+  height: auto;
+}
+button {
+  font-family: inherit;
+  font-size: inherit;
+  border: none;
+  border-radius: 0;
+  margin: 0;
+  padding: 0;
+  background: none;
+  color: inherit;
+  cursor: pointer;
+}
+body {
+  -webkit-overflow-scrolling: touch;
+  -ms-overflow-style: none;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  text-rendering: optimizeLegibility;
+  width: 100%;
+  height: 100%;
+}
+html {
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  color: var(--c-white);
+  font-size: 12px;
+  font-family: var(--f-mono);
+}
+a {
+  text-decoration: none;
+  color: inherit;
+}
+input,
+select,
+textarea {
+  background-color: inherit;
+  color: inherit;
+  border: 0;
+  font-family: inherit;
+  font-style: inherit;
+  font-weight: inherit;
+  font-size: inherit;
+  margin: 0;
+  padding: 0;
+}
+input::placeholder,
+textarea::placeholder {
+  opacity: 1;
+  color: #9f879b;
+}
+fieldset {
+  border: 0;
+  padding: 0;
+}
+.fs-small {
+  font-size: 10px;
+}
+.fs-medium {
+  font-size: 1rem;
+}
+.fs-biggish {
+  font-size: 14px;
+}
+.fs-big {
+  font-size: 15px;
+}
+.fs-bigger {
+  font-size: 16px;
+}
+.fs-large {
+  font-size: 18px;
+}
+.fs-larger {
+  font-size: 20px;
+}
+.fs-huge {
+  font-size: 24px;
+}
+.fs-wide {
+  font-size: 30px;
+}
+.ff-sans {
+  font-family: var(--f-sans);
+}
+.ff-sans em,
+.ff-sans italic {
+  font-family: var(--f-sans-italic);
+  font-style: normal;
+}
+.ff-mono em,
+.ff-mono italic {
+  font-family: var(--f-mono-italic);
+  font-style: normal;
+}
+@media screen and (min-width: 540px) {
+  .sm-fs-medium {
+    font-size: 1rem;
+  }
+  .sm-fs-biggish {
+    font-size: 14px;
+  }
+  .sm-fs-big {
+    font-size: 15px;
+  }
+  .sm-fs-large {
+    font-size: 18px;
+  }
+  .sm-fs-larger {
+    font-size: 20px;
+  }
+  .sm-fs-huge {
+    font-size: 24px;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-fs-big {
+    font-size: 15px;
+  }
+}
+.tac {
+  text-align: center;
+}
+.tar {
+  text-align: right;
+}
+@media screen and (min-width: 540px) {
+  .sm-tar {
+    text-align: right;
+  }
+}
+.copy h3,
+.copy h4,
+.copy h5,
+.copy h6,
+.copy p,
+.copy table {
+  max-width: 700px;
+}
+.copy-h-reset-weight h1,
+.copy-h-reset-weight h2,
+.copy-h-reset-weight h3,
+.copy-h-reset-weight h4,
+.copy-h-reset-weight h5,
+.copy-h-reset-weight h6 {
+  font-weight: inherit;
+}
+.copy-h-reset h1,
+.copy-h-reset h2,
+.copy-h-reset h3,
+.copy-h-reset h4,
+.copy-h-reset h5,
+.copy-h-reset h6 {
+  font-size: inherit;
+  font-style: inherit;
+}
+.copy-h h1,
+.copy-h h2 {
+  font-size: 30px;
+  font-family: var(--f-gothic);
+  padding: 15px 20px;
+  border-bottom: 1px solid var(--c-gray);
+}
+@media screen and (min-width: 1920px) {
+  .copy-h h1,
+  .copy-h h2 {
+    padding: 30px 50px;
+  }
+  .copy-h-front h1,
+  .copy-h-front h2,
+  .copy-h-page h1,
+  .copy-h-page h2 {
+    padding: 2.5rem 4.2rem;
+  }
+}
+.copy-h-page h3,
+.copy-h-page h4,
+.copy-h-page h5,
+.copy-h-page h6 {
+  font-size: inherit;
+}
+.copy-page h3,
+.copy-page h4,
+.copy-page h5,
+.copy-page h6,
+.copy-page p {
+  padding: 0.75rem 0.75rem 2.5rem 0.7rem;
+}
+.copy-page table {
+  margin: 0 0.75rem 2.45em 0.7rem;
+}
+.copy-page table td {
+  width: 7.5rem;
+}
+.copy a {
+  color: var(--c-cyan);
+}
+.ttu {
+  text-transform: uppercase;
+}
+.hvc:hover {
+  color: var(--c-green);
+}
+.checkout-image {
+  width: 120px;
+}
+@media screen and (min-width: 540px) {
+  .checkout-image {
+    width: 220px;
+  }
+}
+.img-page img {
+  max-height: 1000px;
+}
+@media screen and (min-width: 2400px) {
+  .img-page img {
+    max-height: 12000px;
+  }
+}
+.bd-white {
+  border: 1px solid var(--c-white);
+}
+.bdb-pink {
+  border-bottom: 1px solid var(--c-pink);
+}
+.bdb-dark-green {
+  border-bottom: 1px solid var(--c-dark-green);
+}
+.bd-dark-rubin {
+  border: 1px solid var(--c-dark-rubin);
+}
+.bdt-dark-red {
+  border-top: 1px solid var(--c-dark-red);
+}
+.bdb-dark-red {
+  border-bottom: 1px solid var(--c-dark-red);
+}
+.bd-yet-gray {
+  border: 1px solid var(--c-yet-gray);
+}
+.bd-deep-brown {
+  border: 1px solid var(--c-deep-brown);
+}
+.bdt-gray {
+  border-top: 1px solid var(--c-gray);
+}
+.bdb-gray {
+  border-bottom: 1px solid var(--c-gray);
+}
+.bd-dark-gray {
+  border: 1px solid var(--c-dark-gray);
+}
+.bd-brown {
+  border: 1px solid var(--c-brown);
+}
+.bd-blue {
+  border: 1px solid var(--c-blue);
+}
+.bd-bluegreen {
+  border: 1px solid var(--c-bluegreen);
+}
+@media screen and (min-width: 760px) {
+  .md-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+  .md-bdl-rubin {
+    border-left: 1px solid var(--c-rubin);
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+  .bg-bdl-gray {
+    border-left: 1px solid var(--c-gray);
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+  .lg-bdl-gray {
+    border-left: 1px solid var(--c-gray);
+  }
+  .lg-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+}
+.brad-6 {
+  border-radius: 6px;
+}
+.vh {
+  clip: rect(0 0 0 0);
+  -webkit-clip-path: inset(50%);
+  clip-path: inset(50%);
+  height: 1px;
+  overflow: hidden;
+  position: absolute;
+  white-space: nowrap;
+  width: 1px;
+}
+.dn {
+  display: none;
+}
+.dib {
+  display: inline-block;
+}
+.db {
+  display: block;
+}
+.x {
+  display: flex;
+}
+.xw {
+  flex-wrap: wrap;
+}
+.xdr {
+  flex-direction: row;
+}
+.xdc {
+  flex-direction: column;
+}
+.xaic {
+  align-items: center;
+}
+.xaib {
+  align-items: baseline;
+}
+.xjsb {
+  justify-content: space-between;
+}
+.xo1 {
+  order: 1;
+}
+.xo2 {
+  order: 2;
+}
+.xo3 {
+  order: 3;
+}
+.x1 {
+  flex: 1;
+}
+.g-4-16 {
+  gap: 4.16rem;
+}
+@media screen and (min-width: 400px) {
+  .tn-xdr {
+    flex-direction: row;
+  }
+}
+@media screen and (min-width: 540px) {
+  .sm-xaic {
+    align-items: center;
+  }
+  .sm-xo1 {
+    order: 1;
+  }
+  .sm-xo2 {
+    order: 2;
+  }
+  .sm-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-dn {
+    display: none;
+  }
+  .md-db {
+    display: block;
+  }
+  .md-x {
+    display: flex;
+  }
+  .md-xdc {
+    flex-direction: column;
+  }
+  .md-xdr {
+    flex-direction: row;
+  }
+  .md-xw {
+    flex-wrap: wrap;
+  }
+  .md-xo1 {
+    order: 1;
+  }
+  .md-xo2 {
+    order: 2;
+  }
+  .md-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-xw {
+    flex-wrap: wrap;
+  }
+  .bg-xdr {
+    flex-direction: row;
+  }
+  .bg-xdc {
+    flex-direction: column;
+  }
+  .bg-xjsb {
+    justify-content: space-between;
+  }
+  .bg-xo1 {
+    order: 1;
+  }
+  .bg-xo2 {
+    order: 2;
+  }
+  .bg-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-x {
+    display: flex;
+  }
+  .lg-xdr {
+    flex-direction: row;
+  }
+  .lg-xw {
+    flex-wrap: wrap;
+  }
+  .lg-xdc {
+    flex-direction: column;
+  }
+  .lg-xo1 {
+    order: 1;
+  }
+  .lg-xo2 {
+    order: 2;
+  }
+  .lg-xo3 {
+    order: 3;
+  }
+  .lg-xjsb {
+    justify-content: space-between;
+  }
+}
+.dg {
+  display: grid;
+}
+.z1 {
+  z-index: 1;
+}
+.z2 {
+  z-index: 2;
+}
+.psr {
+  position: relative;
+}
+.psf {
+  position: fixed;
+}
+.psa {
+  position: absolute;
+}
+.t0 {
+  top: 0;
+}
+.b0 {
+  bottom: 0;
+}
+.l0 {
+  left: 0;
+}
+.t50 {
+  top: 50%;
+}
+.l50 {
+  left: 50%;
+}
+.ttcx {
+  transform: translateX(-50%);
+}
+.ttcc {
+  transform: translate(-50%, -50%);
+}
+.mta {
+  margin-top: auto;
+}
+.mla {
+  margin-left: auto;
+}
+.mra {
+  margin-right: auto;
+}
+.mt1 {
+  margin-top: 1rem;
+}
+.mr1 {
+  margin-right: 1rem;
+}
+.mr0-5 {
+  margin-right: 0.5rem;
+}
+.ml0-5 {
+  margin-left: 0.5rem;
+}
+.ml1 {
+  margin-left: 1rem;
+}
+.mb1-25 {
+  margin-bottom: 1.25rem;
+}
+.mb4-25 {
+  margin-bottom: 4.25rem;
+}
+.pt0-25 {
+  padding-top: 0.25rem;
+}
+.pr0-25 {
+  padding-right: 0.25rem;
+}
+.pb0-25 {
+  padding-bottom: 0.25rem;
+}
+.pl0-25 {
+  padding-left: 0.25rem;
+}
+.pt0-5 {
+  padding-top: 0.5rem;
+}
+.pr0-5 {
+  padding-right: 0.5rem;
+}
+.pb0-5 {
+  padding-bottom: 0.5rem;
+}
+.pl0-5 {
+  padding-left: 0.5rem;
+}
+.pt0-75 {
+  padding-top: 0.75rem;
+}
+.pr0-75 {
+  padding-right: 0.75rem;
+}
+.pb0-75 {
+  padding-bottom: 0.75rem;
+}
+.pl0-75 {
+  padding-left: 0.75rem;
+}
+.pt0-85 {
+  padding-top: 0.85rem;
+}
+.pr0-85 {
+  padding-right: 0.85rem;
+}
+.pb0-85 {
+  padding-bottom: 0.85rem;
+}
+.pl0-85 {
+  padding-left: 0.85rem;
+}
+.pt1 {
+  padding-top: 1rem;
+}
+.pb1 {
+  padding-bottom: 1rem;
+}
+.pb1-25 {
+  padding-bottom: 1.25rem;
+}
+.pt1-7 {
+  padding-top: 1.7rem;
+}
+.pb1-7 {
+  padding-bottom: 1.7rem;
+}
+.pb2 {
+  padding-bottom: 2rem;
+}
+.pt3-5 {
+  padding-top: 3.5rem;
+}
+.pb3 {
+  padding-bottom: 3rem;
+}
+.pt4-25 {
+  padding-top: 4.25rem;
+}
+.pb4-25 {
+  padding-bottom: 4.25rem;
+}
+.pt4-75 {
+  padding-top: 4.75rem;
+}
+.pb4-75 {
+  padding-bottom: 4.75rem;
+}
+.pb5-5 {
+  padding-bottom: 5.5rem;
+}
+.pt7 {
+  padding-top: 7rem;
+}
+.pb7 {
+  padding-bottom: 7rem;
+}
+@media screen and (min-width: 540px) {
+  .sm-pr0 {
+    padding-right: 0;
+  }
+  .sm-pl0 {
+    padding-left: 0;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-ml1-25 {
+    margin-left: 1.25rem;
+  }
+  .md-pt0-5 {
+    padding-top: 0.5rem;
+  }
+  .md-pr0-5 {
+    padding-right: 0.5rem;
+  }
+  .md-pb0-5 {
+    padding-bottom: 0.5rem;
+  }
+  .md-pl0-5 {
+    padding-left: 0.5rem;
+  }
+  .md-pt1 {
+    padding-top: 1rem;
+  }
+  .md-pr1 {
+    padding-right: 1rem;
+  }
+  .md-pb1 {
+    padding-bottom: 1rem;
+  }
+  .md-pl1 {
+    padding-left: 1rem;
+  }
+  .md-pr1-3 {
+    padding-right: 1.3rem;
+  }
+  .md-pl1-3 {
+    padding-left: 1.3rem;
+  }
+  .md-pt3-35 {
+    padding-top: 3.35rem;
+  }
+  .md-pr3-35 {
+    padding-right: 3.35rem;
+  }
+  .md-pb3-35 {
+    padding-bottom: 3.35rem;
+  }
+  .md-pl3-35 {
+    padding-left: 3.35rem;
+  }
+  .md-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .md-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .md-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .md-pl4-2 {
+    padding-left: 4.2rem;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-pb0 {
+    padding-bottom: 0;
+  }
+  .bg-pt1-25 {
+    padding-top: 1.25rem;
+  }
+  .bg-pr1-25 {
+    padding-right: 1.25rem;
+  }
+  .bg-pb1-25 {
+    padding-bottom: 1.25rem;
+  }
+  .bg-pl1-25 {
+    padding-left: 1.25rem;
+  }
+  .bg-pt1-7 {
+    padding-top: 1.7rem;
+  }
+  .bg-pr1-7 {
+    padding-right: 1.7rem;
+  }
+  .bg-pb1-7 {
+    padding-bottom: 1.7rem;
+  }
+  .bg-pl1-7 {
+    padding-left: 1.7rem;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .bg-pr8-3 {
+    padding-right: 8.3rem;
+  }
+  .bg-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .bg-pl4-2 {
+    padding-left: 4.2rem;
+  }
+  .bg-pl8-3 {
+    padding-left: 8.3rem;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-pb0 {
+    padding-bottom: 0;
+  }
+  .lg-pr2 {
+    padding-right: 2;
+  }
+  .lg-pt0-42 {
+    padding-top: 0.42rem;
+  }
+  .lg-pr0-42 {
+    padding-right: 0.42rem;
+  }
+  .lg-pb0-42 {
+    padding-bottom: 0.42rem;
+  }
+  .lg-pl0-42 {
+    padding-left: 0.42rem;
+  }
+  .lg-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .lg-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .lg-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .lg-pl4-2 {
+    padding-left: 4.2rem;
+  }
+  .lg-pt8-3 {
+    padding-top: 8.3rem;
+  }
+  .lg-pr8-3 {
+    padding-right: 8.3rem;
+  }
+  .lg-pb8-3 {
+    padding-bottom: 8.3rem;
+  }
+  .lg-pl8-3 {
+    padding-left: 8.3rem;
+  }
+}
+.logo-eye {
+  width: 27px;
+  height: 22.7px;
+}
+.logo-hardware {
+  width: 120px;
+  height: 14px;
+}
+@media screen and (min-width: 760px) {
+  .logo-hardware {
+    width: 160px;
+    height: 18px;
+  }
+}
+.bgc-white {
+  background-color: var(--c-white);
+}
+.bgc-gr-black {
+  background:
+    linear-gradient(
+      180deg,
+      var(--c-blackish) 0%,
+      var(--c-dark-black) 100%);
+  background: var(--c-dark-black);
+}
+.bgc-black {
+  background-color: var(--c-black);
+}
+.bgc-blackest {
+  background-color: var(--c-blackest);
+}
+.bgc-gaow {
+  background-color: var(--c-gaow);
+}
+.bgc-bright-black {
+  background-color: var(--c-bright-black);
+}
+.fgc-terre {
+  color: var(--c-terre);
+}
+.fgc-gray {
+  color: var(--c-gray);
+}
+.fgc-dark-gray {
+  color: var(--c-dark-gray);
+}
+.bgc-dark-brown {
+  background-color: var(--c-dark-brown);
+}
+.bgc-dark-bright-brown {
+  background-color: var(--c-dark-bright-brown);
+}
+.fgc-light-gray {
+  color: var(--c-light-gray);
+}
+.fgc-anon-gray {
+  color: var(--c-anon-gray);
+}
+.fgc-green {
+  color: var(--c-green);
+}
+.fgc-light-blue {
+  color: var(--c-light-blue);
+}
+.bgc-dark-blue {
+  background-color: var(--c-dark-blue);
+}
+.bgc-dark-bluegreen {
+  background-color: var(--c-dark-bluegreen);
+}
+.fgc-cyan {
+  color: var(--c-cyan);
+}
+.btn-product-code {
+  padding: 2px 4px 3px 4px;
+}
+.btn-checkout-remove {
+  padding: 5px 7px;
+}
+.btn-submit {
+  padding: 8px 12px 10px 12px;
+}
+.btn-product-soldout {
+  border: 1px solid var(--c-dark-rubin);
+  background-color: var(--c-dark-bright-brown);
+  color: var(--c-light-gray);
+}
+.btn-product-unselectable {
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-black);
+  color: var(--c-light-gray);
+}
+.btn-product-available {
+  cursor: pointer;
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-bluegreen);
+  color: var(--c-cyan);
+}
+.btn-product-available:hover {
+  cursor: pointer;
+  border: 1px solid var(--c-dark-teal);
+  background-color: var(--c-blackish);
+  color: var(--c-another-blue);
+}
+.payment-buttons {
+  gap: 1rem;
+}
+.btn-stripe {
+  width: 88px;
+  height: 42px;
+}
+.btn-nowpayments {
+  width: 170px;
+  height: 42px;
+}
+@media screen and (min-width: 540px) {
+  .btn-stripe {
+    width: 116px;
+    height: 55px;
+  }
+  .btn-nowpayments {
+    width: 220px;
+    height: 55px;
+  }
+}
+.product-size-guide h1,
+.product-size-guide h2,
+.product-size-guide h3,
+.product-size-guide h4,
+.product-size-guide h5,
+.product-size-guide h6 {
+  color: var(--c-another-blue);
+  text-transform: uppercase;
+  padding-bottom: 1.7rem;
+}
+.product-size-guide table {
+  width: 100%;
+}
+.copy table {
+  table-layout: fixed;
+  border-collapse: collapse;
+}
+.copy th,
+.copy td {
+  padding: 4px 0;
+  text-transform: uppercase;
+  font-size: 10px;
+}
+.copy td {
+  border-bottom: 1px solid var(--c-forest-green);
+  text-align: center;
+}
+.copy td:first-child {
+  text-align: left;
+  color: var(--c-light-green);
+}
+.related-products {
+  grid-template-columns: repeat(2, 1fr);
+  overflow: hidden;
+  --gap: 4px;
+  --line-offset: calc(var(--gap) / 2);
+  --line-thickness: 1px;
+  --line-color: var(--c-gray);
+}
+@media screen and (min-width: 760px) {
+  .related-products {
+    grid-template-columns: repeat(4, 1fr);
+  }
+}
+@media screen and (min-width: 2400px) {
+  .related-products {
+    grid-template-columns: repeat(5, 1fr);
+  }
+}
+.related-products::after {
+  content: "";
+  background-color: var(--c-gray);
+  width: 1000%;
+  height: 1px;
+  margin-top: auto;
+}
+.related-products-child {
+  border-bottom: 1px solid var(--c-gray);
+  border-right: 1px solid var(--c-gray);
+}
+.related-products a:hover {
+  color: var(--c-cyan);
+  border-right: 1px solid var(--c-gray);
+}
+.cart-icon:hover {
+  color: var(--c-cyan);
+}
+.fgc-cyan:hover {
+  color: var(--c-pink);
+}
+.copy-checkout-disclaimer {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 20px;
+}
+@media screen and (min-width: 760px) {
+  .copy-checkout-disclaimer {
+    gap: 34px;
+  }
+}
+.sunset {
+  width: 100%;
+  height: 15vh;
+  background-repeat: no-repeat;
+  background-position: bottom;
+}
+.curp {
+  cursor: pointer;
+}
+.cura {
+  cursor: auto;
+}
+.w5r {
+  width: 5rem;
+}
+.w30 {
+  width: 30%;
+}
+.w50 {
+  width: 50%;
+}
+.w70 {
+  width: 70%;
+}
+.w100 {
+  width: 100%;
+}
+.mw-680px {
+  max-width: 680px;
+}
+.h15vh {
+  height: 15vh;
+}
+.h100 {
+  height: 100%;
+}
+@media screen and (min-width: 540px) {
+  .sm-w30 {
+    width: 30%;
+  }
+  .sm-w70 {
+    width: 70%;
+  }
+  .sm-w100 {
+    width: 100%;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-w25 {
+    width: 25%;
+  }
+  .md-w30 {
+    width: 30%;
+  }
+  .md-w50 {
+    width: 50%;
+  }
+  .md-w70 {
+    width: 70%;
+  }
+  .md-w100 {
+    width: 100%;
+  }
+  .md-h100 {
+    height: 100%;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-w25 {
+    width: 25%;
+  }
+  .bg-w50 {
+    width: 50%;
+  }
+  .bg-w75 {
+    width: 75%;
+  }
+  .bg-w100 {
+    width: 100%;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-w25 {
+    width: 25%;
+  }
+  .lg-w75 {
+    width: 75%;
+  }
+  .lg-w50 {
+    width: 50%;
+  }
+}
+@media screen and (min-width: 2400px) {
+  .hg-w1200 {
+    width: 1200px;
+  }
+  .hg-x1 {
+    flex: 1;
+  }
+}
+.nav-height {
+  padding-top: 34.7px;
+}
+@media screen and (min-width: 760px) {
+  .nav-height {
+    padding-top: 46.7px;
+  }
+}
+.form-input {
+  padding: 14.5px 10px;
+  background-color: #00120e;
+  border: 1px solid #213f38;
+}
+@media screen and (min-width: 760px) {
+  .sticky-prod-info {
+    position: -webkit-sticky;
+    position: sticky;
+    top: 48px;
+    background-color: var(--c-black);
+    z-index: 1;
+  }
+}
+.icon-p {
+  width: 12px;
+  height: 12px;
+}

+ 1194 - 0
static/dist/styles-ZJO4NQRT.css

@@ -0,0 +1,1194 @@
+/* static/css/styles.css */
+@font-face {
+  font-family: "IBM Plex Mono Light";
+  src:
+    url("./fonts/IBMPlexMono-Light.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-Light.woff") format("woff"),
+    font-style: normal;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "IBM Plex Mono Light Italic";
+  src:
+    url("./fonts/IBMPlexMono-LightItalic.woff2") format("woff2"),
+    url("./fonts/IBMPlexMono-LightItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 100;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold";
+  src:
+    url("./fonts/Inter-Bold.woff2") format("woff2"),
+    url("./fonts/Inter-Bold.woff") format("woff"),
+    font-style: normal;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Inter Bold Italic";
+  src:
+    url("./fonts/Inter-BoldItalic.woff2") format("woff2"),
+    url("./fonts/Inter-BoldItalic.woff") format("woff"),
+    font-style: italic;
+  font-weight: 900;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Weiss Rund Gotisch";
+  src:
+    url("./fonts/weissrundgotisch.regular.woff2") format("woff2"),
+    url("./fonts/weissrundgotisch.regular.woff") format("woff"),
+    font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+}
+:root {
+  --nav-height: 35px;
+  --c-white: #ffffff;
+  --c-anon-gray: #a5a5a5;
+  --c-light-gray: #dbdbdb;
+  --c-gray: #b7b7b7;
+  --c-pink: #ffbcfc;
+  --c-dark-bright-brown: #241b18;
+  --c-dark-rubin: #3d230b;
+  --c-rubin: #610000;
+  --c-dark-red: #520000;
+  --c-deep-brown: #2B2222;
+  --c-terre: #AA9494;
+  --c-green: #8dff98;
+  --c-forest-green: #4c8042;
+  --c-light-green: #f2ffcd;
+  --c-light-blue: #bffbff;
+  --c-another-blue: #80d1ff;
+  --c-cyan: #00ffff;
+  --c-bluegreen: #002f10;
+  --c-dark-green: #002C29;
+  --c-dark-bluegreen: #001b1d;
+  --c-blue: #00114c;
+  --c-dark-blue: #00111d;
+  --c-brown: #2f2a00;
+  --c-dark-brown: #1a1d00;
+  --c-gray: #4b4b4b;
+  --c-dark-gray: #555353;
+  --c-bright-black: #020713;
+  --c-gaow: #160E0E;
+  --c-blackish: #11181d;
+  --c-dark-black: #090d10;
+  --c-black: #090909;
+  --c-blackest: #000000;
+  --c-yet-gray: #292929;
+  --f-mono: "IBM Plex Mono Light", monospace;
+  --f-mono-italic: "IBM Plex Mono Light Italic", monospace;
+  --f-sans: "Inter Bold", sans-serif;
+  --f-sans-italic: "Inter Bold Italic", sans-serif;
+  --f-gothic: "Weiss Rund Gotisch", serif;
+}
+@media screen and (min-width: 760px) {
+  :root {
+    --nav-height: 47px;
+  }
+}
+html {
+  box-sizing: border-box;
+  scroll-padding-top: var(--nav-height);
+}
+*,
+*:before,
+*:after {
+  box-sizing: inherit;
+}
+html,
+body {
+  scroll-behavior: smooth;
+}
+html,
+body,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+ol,
+ul,
+li,
+figure {
+  margin: 0;
+  padding: 0;
+}
+figure img {
+  display: block;
+}
+ol,
+ul {
+  padding-left: 1rem;
+}
+img {
+  max-width: 100%;
+  height: auto;
+}
+button {
+  font-family: inherit;
+  font-size: inherit;
+  border: none;
+  border-radius: 0;
+  margin: 0;
+  padding: 0;
+  background: none;
+  color: inherit;
+  cursor: pointer;
+}
+body {
+  -webkit-overflow-scrolling: touch;
+  -ms-overflow-style: none;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  text-rendering: optimizeLegibility;
+  width: 100%;
+  height: 100%;
+}
+html {
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  color: var(--c-white);
+  font-size: 12px;
+  font-family: var(--f-mono);
+}
+a {
+  text-decoration: none;
+  color: inherit;
+}
+input,
+select,
+textarea {
+  background-color: inherit;
+  color: inherit;
+  border: 0;
+  font-family: inherit;
+  font-style: inherit;
+  font-weight: inherit;
+  font-size: inherit;
+  margin: 0;
+  padding: 0;
+}
+input::placeholder,
+textarea::placeholder {
+  opacity: 1;
+  color: #9f879b;
+}
+fieldset {
+  border: 0;
+  padding: 0;
+}
+.fs-small {
+  font-size: 10px;
+}
+.fs-medium {
+  font-size: 1rem;
+}
+.fs-biggish {
+  font-size: 14px;
+}
+.fs-big {
+  font-size: 15px;
+}
+.fs-bigger {
+  font-size: 16px;
+}
+.fs-large {
+  font-size: 18px;
+}
+.fs-larger {
+  font-size: 20px;
+}
+.fs-huge {
+  font-size: 24px;
+}
+.fs-wide {
+  font-size: 30px;
+}
+.ff-sans {
+  font-family: var(--f-sans);
+}
+.ff-sans em,
+.ff-sans italic {
+  font-family: var(--f-sans-italic);
+  font-style: normal;
+}
+.ff-mono em,
+.ff-mono italic {
+  font-family: var(--f-mono-italic);
+  font-style: normal;
+}
+@media screen and (min-width: 540px) {
+  .sm-fs-medium {
+    font-size: 1rem;
+  }
+  .sm-fs-biggish {
+    font-size: 14px;
+  }
+  .sm-fs-big {
+    font-size: 15px;
+  }
+  .sm-fs-large {
+    font-size: 18px;
+  }
+  .sm-fs-larger {
+    font-size: 20px;
+  }
+  .sm-fs-huge {
+    font-size: 24px;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-fs-big {
+    font-size: 15px;
+  }
+}
+.tac {
+  text-align: center;
+}
+.tar {
+  text-align: right;
+}
+@media screen and (min-width: 540px) {
+  .sm-tar {
+    text-align: right;
+  }
+}
+.copy h3,
+.copy h4,
+.copy h5,
+.copy h6,
+.copy p,
+.copy table {
+  max-width: 700px;
+}
+.copy-h-reset-weight h1,
+.copy-h-reset-weight h2,
+.copy-h-reset-weight h3,
+.copy-h-reset-weight h4,
+.copy-h-reset-weight h5,
+.copy-h-reset-weight h6 {
+  font-weight: inherit;
+}
+.copy-h-reset h1,
+.copy-h-reset h2,
+.copy-h-reset h3,
+.copy-h-reset h4,
+.copy-h-reset h5,
+.copy-h-reset h6 {
+  font-size: inherit;
+  font-style: inherit;
+}
+.copy-h h1,
+.copy-h h2 {
+  font-size: 30px;
+  font-family: var(--f-gothic);
+  padding: 15px 20px;
+  border-bottom: 1px solid var(--c-gray);
+}
+@media screen and (min-width: 1920px) {
+  .copy-h h1,
+  .copy-h h2 {
+    padding: 30px 50px;
+  }
+  .copy-h-front h1,
+  .copy-h-front h2,
+  .copy-h-page h1,
+  .copy-h-page h2 {
+    padding: 2.5rem 4.2rem;
+  }
+}
+.copy-h-page h3,
+.copy-h-page h4,
+.copy-h-page h5,
+.copy-h-page h6 {
+  font-size: inherit;
+}
+.copy-page h3,
+.copy-page h4,
+.copy-page h5,
+.copy-page h6,
+.copy-page p {
+  padding: 0.75rem 0.75rem 2.5rem 0.7rem;
+}
+.copy-page table {
+  margin: 0 0.75rem 2.45em 0.7rem;
+}
+.copy-page table td {
+  width: 7.5rem;
+}
+.copy a {
+  color: var(--c-cyan);
+}
+.ttu {
+  text-transform: uppercase;
+}
+.hvc:hover {
+  color: var(--c-green);
+}
+.checkout-image {
+  width: 120px;
+}
+@media screen and (min-width: 540px) {
+  .checkout-image {
+    width: 220px;
+  }
+}
+.img-page img {
+  max-height: 1000px;
+}
+@media screen and (min-width: 2400px) {
+  .img-page img {
+    max-height: 12000px;
+  }
+}
+.bd-white {
+  border: 1px solid var(--c-white);
+}
+.bdb-pink {
+  border-bottom: 1px solid var(--c-pink);
+}
+.bdb-dark-green {
+  border-bottom: 1px solid var(--c-dark-green);
+}
+.bd-dark-rubin {
+  border: 1px solid var(--c-dark-rubin);
+}
+.bdb-dark-red {
+  border-bottom: 1px solid var(--c-dark-red);
+}
+.bd-yet-gray {
+  border: 1px solid var(--c-yet-gray);
+}
+.bd-deep-brown {
+  border: 1px solid var(--c-deep-brown);
+}
+.bdt-gray {
+  border-top: 1px solid var(--c-gray);
+}
+.bdb-gray {
+  border-bottom: 1px solid var(--c-gray);
+}
+.bd-dark-gray {
+  border: 1px solid var(--c-dark-gray);
+}
+.bd-brown {
+  border: 1px solid var(--c-brown);
+}
+.bd-blue {
+  border: 1px solid var(--c-blue);
+}
+.bd-bluegreen {
+  border: 1px solid var(--c-bluegreen);
+}
+@media screen and (min-width: 760px) {
+  .md-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+  .md-bdl-rubin {
+    border-left: 1px solid var(--c-rubin);
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+  .bg-bdl-gray {
+    border-left: 1px solid var(--c-gray);
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-bdr-gray {
+    border-right: 1px solid var(--c-gray);
+  }
+  .lg-bdl-gray {
+    border-left: 1px solid var(--c-gray);
+  }
+  .lg-bdl-pink {
+    border-left: 1px solid var(--c-pink);
+  }
+}
+.brad-6 {
+  border-radius: 6px;
+}
+.vh {
+  clip: rect(0 0 0 0);
+  -webkit-clip-path: inset(50%);
+  clip-path: inset(50%);
+  height: 1px;
+  overflow: hidden;
+  position: absolute;
+  white-space: nowrap;
+  width: 1px;
+}
+.dn {
+  display: none;
+}
+.dib {
+  display: inline-block;
+}
+.db {
+  display: block;
+}
+.x {
+  display: flex;
+}
+.xw {
+  flex-wrap: wrap;
+}
+.xdr {
+  flex-direction: row;
+}
+.xdc {
+  flex-direction: column;
+}
+.xaic {
+  align-items: center;
+}
+.xaib {
+  align-items: baseline;
+}
+.xjsb {
+  justify-content: space-between;
+}
+.xo1 {
+  order: 1;
+}
+.xo2 {
+  order: 2;
+}
+.xo3 {
+  order: 3;
+}
+@media screen and (min-width: 400px) {
+  .tn-xdr {
+    flex-direction: row;
+  }
+}
+@media screen and (min-width: 540px) {
+  .sm-xaic {
+    align-items: center;
+  }
+  .sm-xo1 {
+    order: 1;
+  }
+  .sm-xo2 {
+    order: 2;
+  }
+  .sm-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-dn {
+    display: none;
+  }
+  .md-db {
+    display: block;
+  }
+  .md-x {
+    display: flex;
+  }
+  .md-xdc {
+    flex-direction: column;
+  }
+  .md-xdr {
+    flex-direction: row;
+  }
+  .md-xw {
+    flex-wrap: wrap;
+  }
+  .md-xo1 {
+    order: 1;
+  }
+  .md-xo2 {
+    order: 2;
+  }
+  .md-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-xw {
+    flex-wrap: wrap;
+  }
+  .bg-xdr {
+    flex-direction: row;
+  }
+  .bg-xjsb {
+    justify-content: space-between;
+  }
+  .bg-xo1 {
+    order: 1;
+  }
+  .bg-xo2 {
+    order: 2;
+  }
+  .bg-xo3 {
+    order: 3;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-x {
+    display: flex;
+  }
+  .lg-xdr {
+    flex-direction: row;
+  }
+  .lg-xw {
+    flex-wrap: wrap;
+  }
+  .lg-xdc {
+    flex-direction: column;
+  }
+  .lg-xo1 {
+    order: 1;
+  }
+  .lg-xo2 {
+    order: 2;
+  }
+  .lg-xo3 {
+    order: 3;
+  }
+  .lg-xjsb {
+    justify-content: space-between;
+  }
+}
+.dg {
+  display: grid;
+}
+.z1 {
+  z-index: 1;
+}
+.z2 {
+  z-index: 2;
+}
+.psr {
+  position: relative;
+}
+.psf {
+  position: fixed;
+}
+.psa {
+  position: absolute;
+}
+.t0 {
+  top: 0;
+}
+.b0 {
+  bottom: 0;
+}
+.l0 {
+  left: 0;
+}
+.t50 {
+  top: 50%;
+}
+.l50 {
+  left: 50%;
+}
+.ttcx {
+  transform: translateX(-50%);
+}
+.ttcc {
+  transform: translate(-50%, -50%);
+}
+.mta {
+  margin-top: auto;
+}
+.mla {
+  margin-left: auto;
+}
+.mra {
+  margin-right: auto;
+}
+.mt1 {
+  margin-top: 1rem;
+}
+.mr1 {
+  margin-right: 1rem;
+}
+.mr0-5 {
+  margin-right: 0.5rem;
+}
+.ml0-5 {
+  margin-left: 0.5rem;
+}
+.ml1 {
+  margin-left: 1rem;
+}
+.mb1-25 {
+  margin-bottom: 1.25rem;
+}
+.mb4-25 {
+  margin-bottom: 4.25rem;
+}
+.pt0-25 {
+  padding-top: 0.25rem;
+}
+.pr0-25 {
+  padding-right: 0.25rem;
+}
+.pb0-25 {
+  padding-bottom: 0.25rem;
+}
+.pl0-25 {
+  padding-left: 0.25rem;
+}
+.pt0-5 {
+  padding-top: 0.5rem;
+}
+.pr0-5 {
+  padding-right: 0.5rem;
+}
+.pb0-5 {
+  padding-bottom: 0.5rem;
+}
+.pl0-5 {
+  padding-left: 0.5rem;
+}
+.pt0-75 {
+  padding-top: 0.75rem;
+}
+.pr0-75 {
+  padding-right: 0.75rem;
+}
+.pb0-75 {
+  padding-bottom: 0.75rem;
+}
+.pl0-75 {
+  padding-left: 0.75rem;
+}
+.pt0-85 {
+  padding-top: 0.85rem;
+}
+.pr0-85 {
+  padding-right: 0.85rem;
+}
+.pb0-85 {
+  padding-bottom: 0.85rem;
+}
+.pl0-85 {
+  padding-left: 0.85rem;
+}
+.pt1 {
+  padding-top: 1rem;
+}
+.pb1 {
+  padding-bottom: 1rem;
+}
+.pb1-25 {
+  padding-bottom: 1.25rem;
+}
+.pt1-7 {
+  padding-top: 1.7rem;
+}
+.pb1-7 {
+  padding-bottom: 1.7rem;
+}
+.pb2 {
+  padding-bottom: 2rem;
+}
+.pt3-5 {
+  padding-top: 3.5rem;
+}
+.pb3 {
+  padding-bottom: 3rem;
+}
+.pt4-25 {
+  padding-top: 4.25rem;
+}
+.pb4-25 {
+  padding-bottom: 4.25rem;
+}
+.pt4-75 {
+  padding-top: 4.75rem;
+}
+.pb4-75 {
+  padding-bottom: 4.75rem;
+}
+.pb5-5 {
+  padding-bottom: 5.5rem;
+}
+.pt7 {
+  padding-top: 7rem;
+}
+.pb7 {
+  padding-bottom: 7rem;
+}
+@media screen and (min-width: 540px) {
+  .sm-pr0 {
+    padding-right: 0;
+  }
+  .sm-pl0 {
+    padding-left: 0;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-ml1-25 {
+    margin-left: 1.25rem;
+  }
+  .md-pt0-5 {
+    padding-top: 0.5rem;
+  }
+  .md-pr0-5 {
+    padding-right: 0.5rem;
+  }
+  .md-pb0-5 {
+    padding-bottom: 0.5rem;
+  }
+  .md-pl0-5 {
+    padding-left: 0.5rem;
+  }
+  .md-pt1 {
+    padding-top: 1rem;
+  }
+  .md-pr1 {
+    padding-right: 1rem;
+  }
+  .md-pb1 {
+    padding-bottom: 1rem;
+  }
+  .md-pl1 {
+    padding-left: 1rem;
+  }
+  .md-pr1-3 {
+    padding-right: 1.3rem;
+  }
+  .md-pl1-3 {
+    padding-left: 1.3rem;
+  }
+  .md-pt3-35 {
+    padding-top: 3.35rem;
+  }
+  .md-pr3-35 {
+    padding-right: 3.35rem;
+  }
+  .md-pb3-35 {
+    padding-bottom: 3.35rem;
+  }
+  .md-pl3-35 {
+    padding-left: 3.35rem;
+  }
+  .md-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .md-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .md-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .md-pl4-2 {
+    padding-left: 4.2rem;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-pb0 {
+    padding-bottom: 0;
+  }
+  .bg-pt1-25 {
+    padding-top: 1.25rem;
+  }
+  .bg-pr1-25 {
+    padding-right: 1.25rem;
+  }
+  .bg-pb1-25 {
+    padding-bottom: 1.25rem;
+  }
+  .bg-pl1-25 {
+    padding-left: 1.25rem;
+  }
+  .bg-pt1-7 {
+    padding-top: 1.7rem;
+  }
+  .bg-pr1-7 {
+    padding-right: 1.7rem;
+  }
+  .bg-pb1-7 {
+    padding-bottom: 1.7rem;
+  }
+  .bg-pl1-7 {
+    padding-left: 1.7rem;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .bg-pr8-3 {
+    padding-right: 8.3rem;
+  }
+  .bg-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .bg-pl4-2 {
+    padding-left: 4.2rem;
+  }
+  .bg-pl8-3 {
+    padding-left: 8.3rem;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-pb0 {
+    padding-bottom: 0;
+  }
+  .lg-pr2 {
+    padding-right: 2;
+  }
+  .lg-pt0-42 {
+    padding-top: 0.42rem;
+  }
+  .lg-pr0-42 {
+    padding-right: 0.42rem;
+  }
+  .lg-pb0-42 {
+    padding-bottom: 0.42rem;
+  }
+  .lg-pl0-42 {
+    padding-left: 0.42rem;
+  }
+  .lg-pt4-2 {
+    padding-top: 4.2rem;
+  }
+  .lg-pr4-2 {
+    padding-right: 4.2rem;
+  }
+  .lg-pb4-2 {
+    padding-bottom: 4.2rem;
+  }
+  .lg-pl4-2 {
+    padding-left: 4.2rem;
+  }
+  .lg-pt8-3 {
+    padding-top: 8.3rem;
+  }
+  .lg-pr8-3 {
+    padding-right: 8.3rem;
+  }
+  .lg-pb8-3 {
+    padding-bottom: 8.3rem;
+  }
+  .lg-pl8-3 {
+    padding-left: 8.3rem;
+  }
+}
+.logo-eye {
+  width: 27px;
+  height: 22.7px;
+}
+.logo-hardware {
+  width: 120px;
+  height: 14px;
+}
+@media screen and (min-width: 760px) {
+  .logo-hardware {
+    width: 160px;
+    height: 18px;
+  }
+}
+.bgc-white {
+  background-color: var(--c-white);
+}
+.bgc-gr-black {
+  background:
+    linear-gradient(
+      180deg,
+      var(--c-blackish) 0%,
+      var(--c-dark-black) 100%);
+  background: var(--c-dark-black);
+}
+.bgc-black {
+  background-color: var(--c-black);
+}
+.bgc-blackest {
+  background-color: var(--c-blackest);
+}
+.bgc-gaow {
+  background-color: var(--c-gaow);
+}
+.bgc-bright-black {
+  background-color: var(--c-bright-black);
+}
+.fgc-terre {
+  color: var(--c-terre);
+}
+.fgc-gray {
+  color: var(--c-gray);
+}
+.fgc-dark-gray {
+  color: var(--c-dark-gray);
+}
+.bgc-dark-brown {
+  background-color: var(--c-dark-brown);
+}
+.bgc-dark-bright-brown {
+  background-color: var(--c-dark-bright-brown);
+}
+.fgc-light-gray {
+  color: var(--c-light-gray);
+}
+.fgc-anon-gray {
+  color: var(--c-anon-gray);
+}
+.fgc-green {
+  color: var(--c-green);
+}
+.fgc-light-blue {
+  color: var(--c-light-blue);
+}
+.bgc-dark-blue {
+  background-color: var(--c-dark-blue);
+}
+.bgc-dark-bluegreen {
+  background-color: var(--c-dark-bluegreen);
+}
+.fgc-cyan {
+  color: var(--c-cyan);
+}
+.btn-product-code {
+  padding: 2px 4px 3px 4px;
+}
+.btn-checkout-remove {
+  padding: 5px 7px;
+}
+.btn-submit {
+  padding: 8px 12px 10px 12px;
+}
+.btn-product-soldout {
+  border: 1px solid var(--c-dark-rubin);
+  background-color: var(--c-dark-bright-brown);
+  color: var(--c-light-gray);
+}
+.btn-product-unselectable {
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-black);
+  color: var(--c-light-gray);
+}
+.btn-product-available {
+  cursor: pointer;
+  border: 1px solid var(--c-bluegreen);
+  background-color: var(--c-dark-bluegreen);
+  color: var(--c-cyan);
+}
+.payment-buttons {
+  gap: 1rem;
+}
+.btn-stripe {
+  width: 88px;
+  height: 42px;
+}
+.btn-nowpayments {
+  width: 170px;
+  height: 42px;
+}
+@media screen and (min-width: 540px) {
+  .btn-stripe {
+    width: 116px;
+    height: 55px;
+  }
+  .btn-nowpayments {
+    width: 220px;
+    height: 55px;
+  }
+}
+.product-size-guide h1,
+.product-size-guide h2,
+.product-size-guide h3,
+.product-size-guide h4,
+.product-size-guide h5,
+.product-size-guide h6 {
+  color: var(--c-another-blue);
+  text-transform: uppercase;
+  padding-bottom: 1.7rem;
+}
+.product-size-guide table {
+  width: 100%;
+}
+.copy table {
+  table-layout: fixed;
+  border-collapse: collapse;
+}
+.copy th,
+.copy td {
+  padding: 4px 0;
+  text-transform: uppercase;
+  font-size: 10px;
+}
+.copy td {
+  border-bottom: 1px solid var(--c-forest-green);
+  text-align: center;
+}
+.copy td:first-child {
+  text-align: left;
+  color: var(--c-light-green);
+}
+.related-products {
+  grid-template-columns: repeat(2, 1fr);
+  overflow: hidden;
+  --gap: 4px;
+  --line-offset: calc(var(--gap) / 2);
+  --line-thickness: 1px;
+  --line-color: var(--c-gray);
+}
+@media screen and (min-width: 760px) {
+  .related-products {
+    grid-template-columns: repeat(4, 1fr);
+  }
+}
+@media screen and (min-width: 2400px) {
+  .related-products {
+    grid-template-columns: repeat(5, 1fr);
+  }
+}
+.related-products::after {
+  content: "";
+  background-color: var(--c-gray);
+  width: 1000%;
+  height: 1px;
+  margin-top: auto;
+}
+.related-products-child {
+  border-bottom: 1px solid var(--c-gray);
+  border-right: 1px solid var(--c-gray);
+}
+.copy-checkout-disclaimer {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 20px;
+}
+@media screen and (min-width: 760px) {
+  .copy-checkout-disclaimer {
+    gap: 34px;
+  }
+}
+.sunset {
+  width: 100%;
+  height: 15vh;
+  background-repeat: no-repeat;
+  background-position: bottom;
+}
+.curp {
+  cursor: pointer;
+}
+.cura {
+  cursor: auto;
+}
+.w5r {
+  width: 5rem;
+}
+.w30 {
+  width: 30%;
+}
+.w50 {
+  width: 50%;
+}
+.w70 {
+  width: 70%;
+}
+.w100 {
+  width: 100%;
+}
+.mw-680px {
+  max-width: 680px;
+}
+.h15vh {
+  height: 15vh;
+}
+.h100 {
+  height: 100%;
+}
+@media screen and (min-width: 540px) {
+  .sm-w30 {
+    width: 30%;
+  }
+  .sm-w70 {
+    width: 70%;
+  }
+  .sm-w100 {
+    width: 100%;
+  }
+}
+@media screen and (min-width: 760px) {
+  .md-w25 {
+    width: 25%;
+  }
+  .md-w30 {
+    width: 30%;
+  }
+  .md-w50 {
+    width: 50%;
+  }
+  .md-w70 {
+    width: 70%;
+  }
+  .md-w100 {
+    width: 100%;
+  }
+  .md-h100 {
+    height: 100%;
+  }
+}
+@media screen and (min-width: 1280px) {
+  .bg-w25 {
+    width: 25%;
+  }
+  .bg-w50 {
+    width: 50%;
+  }
+  .bg-w75 {
+    width: 75%;
+  }
+  .bg-w100 {
+    width: 100%;
+  }
+}
+@media screen and (min-width: 1920px) {
+  .lg-w25 {
+    width: 25%;
+  }
+  .lg-w75 {
+    width: 75%;
+  }
+  .lg-w50 {
+    width: 50%;
+  }
+}
+@media screen and (min-width: 2400px) {
+  .hg-w1200 {
+    width: 1200px;
+  }
+  .hg-x1 {
+    flex: 1;
+  }
+}
+.nav-height {
+  padding-top: 34.7px;
+}
+@media screen and (min-width: 760px) {
+  .nav-height {
+    padding-top: 46.7px;
+  }
+}
+.form-input {
+  padding: 14.5px 10px;
+  background-color: #00120e;
+  border: 1px solid #213f38;
+}
+@media screen and (min-width: 760px) {
+  .sticky-prod-info {
+    position: -webkit-sticky;
+    position: sticky;
+    top: 48px;
+  }
+}
+.icon-p {
+  width: 12px;
+  height: 12px;
+}

binární
static/favicon-16x16.png


binární
static/favicon-32x32.png


binární
static/favicon.ico


binární
static/favicon.png


binární
static/fonts/IBMPlexMono-Light.woff


binární
static/fonts/IBMPlexMono-Light.woff2


binární
static/fonts/IBMPlexMono-LightItalic.woff


binární
static/fonts/IBMPlexMono-LightItalic.woff2


binární
static/fonts/Inter-Bold.woff


binární
static/fonts/Inter-Bold.woff2


binární
static/fonts/Inter-BoldItalic.woff


binární
static/fonts/Inter-BoldItalic.woff2


binární
static/fonts/weissrundgotisch.regular.woff


binární
static/fonts/weissrundgotisch.regular.woff2


binární
static/images/coinpayments.png


binární
static/images/sunset-green.png


binární
static/images/sunset-red.png


+ 170 - 0
static/js/cart.js

@@ -0,0 +1,170 @@
+"use strict";
+
+async function updateCart(form) {
+  // make formData object with data from form
+  // and send it to backend endpoint to be processed.
+  // received updated dictionary to use for
+  // updating DOM.
+
+  const formData = new FormData(form);
+
+  const res = await fetch("/checkout?js=true", {
+    method: "POST",
+    body: formData
+  });
+
+  if (res.ok) {
+    return res.json();
+  } else {
+    return {status: res.status, error: res.statusText};
+  }
+}
+
+function updateCartIcon(data) {
+  const cart_icon = document.querySelector('.cart-icon');
+
+  if (cart_icon.length !== null) {
+
+    // update cart amount value
+    let cart_icon_amount_list = cart_icon.querySelector(".cart-icon--amount");
+    if (cart_icon_amount_list.length !== null) {
+      cart_icon_amount_list.innerText = data.cart_breakdown.item_amount;
+    }
+
+    // -- show visual feedback for the cart icon action
+    cart_icon.dataset.status = 'busy';
+    cart_icon.style.color = '#FF7070';
+    cart_icon.style.textDecorationColor = '#FF7070';
+
+    setTimeout(() => {
+      cart_icon.dataset.status = 'free';
+      cart_icon.style.color = '#fff';
+      cart_icon.style.textDecorationColor = '#fff';
+    }, '2000');
+  }
+}
+
+function updateItemRow(item_row, cart_items, item_amount) {
+  // map over each item in the DOM and check if 
+  // it exists in the cart. update its quantity and total price.
+  // if item quantity goes down to 0, remove it
+  // from the DOM.
+
+  let item = cart_items.find(item => item.product_id == item_row.id);
+  if (item) {
+
+    // update quantity value for input field quantity_manual
+    item_row.querySelector("#quantity_manual").value = item.quantity;
+
+    // check if item has been overbooked and disable + button
+    // if on the other side, the item quantity has decreased below
+    // the inventory amount, re-enable the + button
+    let buttonPlus = item_row.querySelector('#button-plus');
+    if (!item.is_selectable) {
+      buttonPlus.setAttribute('disabled', 'disabled');
+      buttonPlus.classList.add('cura');
+    } else {
+      buttonPlus.removeAttribute('disabled');
+      buttonPlus.classList.remove('cura');
+    }
+
+    // update total
+    let total = item_row.querySelector('.item-total');
+    total.querySelector('.item-total--text').textContent = `${item.total}`;
+    total.querySelector('#price').value = item.total;
+
+  } else {
+    // if item in DOM does not exists in the cart, remove it
+    item_row.remove();
+  }
+}
+
+function updateBreakdown(cart_breakdown) {
+  // update checkout's breakdown price
+  // subtotal + shipping + total
+
+  let subtotal_amount = Array.from(document.querySelectorAll('.subtotal--amount > .amount'));
+  subtotal_amount.forEach(subtotal => {
+    subtotal.textContent = `${cart_breakdown.subtotal}`;
+  });
+
+  let shipping = document.querySelectorAll('.subtotal--shipping > .amount');
+  shipping.forEach(shipping => {
+    shipping.textContent = `${cart_breakdown.shipping.toFixed(1)}`;
+  });
+
+  let subtotal_total = document.querySelector('.subtotal--total > .amount');
+  if (subtotal_total) {
+    subtotal_total.textContent = `${cart_breakdown.total.toFixed(1)}`;
+  };
+
+}
+
+function removeCheckoutTable() {
+  // remove DOM elements that lay out the
+  // checkout table.
+
+  document.querySelector(".checkout-table-header").remove();
+  document.querySelector(".checkout-shipping-info").remove();
+  document.querySelector(".checkout-empty-message").classList.remove("dn");
+}
+
+function updateTable(cart_items, cart_item_rows, cart_breakdown) {
+  // map over cart item in the DOM and update them
+  // update breakdown price if cart item is not empty
+  // else remove checkout table
+
+  cart_item_rows.map(item_row => {
+    updateItemRow(item_row, cart_items, cart_breakdown['item_amount']);
+  });
+
+  if (cart_items.length > 0) {
+    updateBreakdown(cart_breakdown);
+
+  } else {
+    removeCheckoutTable();
+
+  }
+
+}
+
+function displayProductSelectable(btnSubmit, isProductSelectable, isProductAvailable) {
+  if (!btnSubmit) {
+    return;
+  }
+
+  // reset button styles
+  btnSubmit.classList.remove('btn-product-available',
+			     'btn-product-available',
+			     'btn-product-unselectable');
+
+  if (!isProductAvailable) {
+    
+    btnSubmit.setAttribute('disabled', 'disabled');
+    btnSubmit.classList.add('btn-product-soldout');
+    btnSubmit.value = "sold out";
+    
+  } else if (!isProductSelectable) {
+
+    btnSubmit.setAttribute('disabled', 'disabled');
+    btnSubmit.classList.add('btn-product-unselectable');
+    btnSubmit.value = "add to cart";
+
+  } else  {
+
+    btnSubmit.removeAttribute('disabled');
+    btnSubmit.classList.add('btn-product-available');
+    btnSubmit.value = "add to cart";
+
+  }
+}
+
+function updateProductURL(product_selected) {
+  if ('URLSearchParams' in window) {
+    const searchParams = new URLSearchParams(product_selected);
+    history.pushState(null, '', `?${searchParams.toString()}`);
+  }
+}
+
+
+export { updateCart, updateCartIcon, updateTable, updateProductURL, displayProductSelectable, updateBreakdown };

+ 122 - 0
static/js/checkout.js

@@ -0,0 +1,122 @@
+import { updateCart, updateCartIcon, updateTable, displayProductSelectable, updateBreakdown } from './cart.js';
+
+
+async function processForm(form) {
+  // handle form by preventing default behaviour
+  // and instead manipulate data appropriately:
+  // - send form to backend via POST
+  // - receive response and update DOM w/o hard-refresh
+
+  form.addEventListener('submit', async(e) => {
+    e.preventDefault();
+
+    // -- update form data
+    const data = await updateCart(form);
+
+    // -- update cart icon
+    updateCartIcon(data);
+
+    // -- checkout: update item row
+    const cart_items = data.cart_items;
+    const cart_breakdown = data.cart_breakdown;
+    const cart_item_rows = Array.from(document.querySelectorAll(".cart-table-row"));
+
+    updateTable(cart_items, cart_item_rows, cart_breakdown);
+
+    // -- product: display product availability
+    if ('is_product_available' in data) {
+      const isProductSelectable = data.is_product_selectable;
+      const isProductAvailable = data.is_product_available;
+
+      // -- product: display product availability
+      const btnSubmit = document.querySelector('.btn-submit');
+
+      displayProductSelectable(btnSubmit, isProductSelectable, isProductAvailable);
+    };
+
+  });
+};
+
+async function updateShippingCost(country, weight, csrftoken) {
+  
+  const formData = new FormData();
+  formData.append("country", country);
+  formData.append("weight", weight);
+  formData.append("csrftoken", csrftoken);
+  
+  const res = await fetch("/checkout/shipping?js=true", {
+    method: "POST",
+    body: formData
+  });
+
+  if (res.ok) {
+    return res.json();
+  } else {
+    return {status: res.status, error: res.statusText};
+  }
+}
+
+function prepareShippingCost(selectCountry) {
+  
+  let weight = document.querySelector('.checkout-shipping-info > input[name="weight"]');
+  if (weight !== undefined) {
+    weight = Number(weight.value);
+  }
+
+  let csrftoken = '';
+  const token = document.querySelector('.checkout-shipping-info > input[name="csrftoken"]');
+  if (token !== undefined) {
+    csrftoken = token.value;
+  }
+
+  let country = '';
+  if (selectCountry.value !== '') {
+    country = selectCountry.value; 
+  }
+
+  if (!isNaN(weight) && csrftoken !== '' && country !== '') {
+    return {
+      weight: weight,
+      csrftoken: csrftoken,
+      country: country,
+    };
+  }
+}
+
+async function setShippingCost() {
+  // on page-load and
+  // on select-country change,
+  // get selected item and make a POST request
+  // to calculate the shipping price + return it
+  
+  const selectCountry = document.querySelector('select#country');
+  if (selectCountry !== undefined && selectCountry !== null) {
+
+    // update shipping costs on page load
+    const data = prepareShippingCost(selectCountry);
+    if (data !== undefined) {
+      const cart_breakdown = await updateShippingCost(data.country, data.weight, data.csrftoken);
+      updateBreakdown(cart_breakdown);
+    };
+    
+    // update shipping cost on select change
+    selectCountry.addEventListener('change', async(e) => {
+      const data = prepareShippingCost(selectCountry);
+      if (data !== undefined) {
+	const cart_breakdown = await updateShippingCost(data.country, data.weight, data.csrftoken);
+	updateBreakdown(cart_breakdown);
+      };
+      
+    });
+  };
+}
+
+window.addEventListener('load', () => {
+  ;(async() => {
+
+    const forms = Array.from(document.querySelectorAll('.checkout-item'));
+    await Promise.all(forms.map(async(form) => processForm(form)));
+
+    await setShippingCost();
+  })();
+});

+ 97 - 0
static/js/product.js

@@ -0,0 +1,97 @@
+import { updateCart,
+	 updateCartIcon,
+	 updateTable,
+	 updateProductURL,
+	 displayProductSelectable } from './cart.js';
+
+
+async function processForm(form) {
+  // handle form by preventing default behaviour
+  // and instead manipulate data appropriately:
+  // - send form to backend via POST
+  // - receive response and update DOM w/o hard-refresh
+
+  form.addEventListener('submit', async(e) => {
+    e.preventDefault();
+
+    // -- update form data
+    const data = await updateCart(form);
+
+    // -- update cart icon
+    updateCartIcon(data);
+
+    // -- display product availability
+    const btnSubmit = document.querySelector('.btn-submit');
+    const isProductSelectable = data.is_product_selectable;
+    const isProductAvailable = data.is_product_available;
+    displayProductSelectable(btnSubmit, isProductSelectable, isProductAvailable);
+
+    // -- update URL
+    updateProductURL(data.product_selected);
+
+  });
+};
+
+async function checkProductAvailability(product_path, searchParams){
+  const res = await fetch(`/${product_path}?js=true&${searchParams.toString()}`);
+
+  if (res.ok) {
+    return res.json();
+  } else {
+    return {status: res.status, error: res.statusText};
+  }
+};
+
+async function changeProductURLOnInputRadio(form) {
+  // add on-change listener to all .input-listen input fields
+  // and update URL accordingly
+  
+  const inputs = Array.from(form.querySelectorAll('.input-listen'));
+  await Promise.all(inputs.map(async(input) => {
+    input.addEventListener('change', async(e) => {
+
+      let key = e.target.name;
+      let value = e.target.value;
+
+      if ('URLSearchParams' in window) {
+
+	// send request to `/products/<product-id>?<params>`
+	// to get back an instant result for this and update UI
+	const product_path = form.querySelector('#product_path').value;
+	const searchParams = new URLSearchParams(window.location.search);
+	searchParams.set(key, value);
+
+	// update URL only if a key combination of size and style are selected,
+	// else do nothing — otherwise the item would be seen as sold out.
+	let size = searchParams.get('size');
+	let style = searchParams.get('style');
+
+	if (size && style) {
+	  const data = await checkProductAvailability(product_path, searchParams);
+
+	  // -- update product availability
+	  const btnSubmit = document.querySelector('.btn-submit');
+	  const isProductSelectable = data.is_product_selectable;
+	  const isProductAvailable = data.is_product_available;
+	  displayProductSelectable(btnSubmit, isProductSelectable, isProductAvailable);
+	}	
+
+	history.pushState(null, '', `?${searchParams.toString()}`);
+
+      };
+      
+    });    
+  }));
+}
+
+window.addEventListener('load', () => {
+  ;(async() => {
+
+    const forms = Array.from(document.querySelectorAll('.product-item'));
+    await Promise.all(forms.map(async(form) => {
+      processForm(form);
+      changeProductURLOnInputRadio(form);
+    }));
+
+  })();
+});

+ 1 - 0
static/site.webmanifest

@@ -0,0 +1 @@
+{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů