diff options
| author | unwox <me@unwox.com> | 2025-10-03 11:56:37 +0600 |
|---|---|---|
| committer | unwox <me@unwox.com> | 2025-10-13 23:11:01 +0600 |
| commit | 3f5ade2e7a139bb4405437e8fc5546aafc7b05ef (patch) | |
| tree | 77c437958d74b591f11ec207d16749cf207a51e3 | |
| parent | f5a70e6a446e00969adb866ef2e2d10bf33bc4a8 (diff) | |
WIP shop
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | .nvim.lua | 2 | ||||
| -rw-r--r-- | bin/serve.fnl | 39 | ||||
| -rw-r--r-- | dicts.fnl | 8 | ||||
| -rw-r--r-- | forms.fnl | 8 | ||||
| -rw-r--r-- | lib.fnl | 123 | ||||
| -rw-r--r-- | macros.fnl | 11 | ||||
| -rw-r--r-- | pages/auth.fnl | 6 | ||||
| -rw-r--r-- | pages/index.fnl | 21 | ||||
| -rw-r--r-- | pages/shop/_product/edit.fnl | 6 | ||||
| -rw-r--r-- | pages/shop/_product/index.fnl | 42 | ||||
| -rw-r--r-- | pages/shop/add.fnl | 7 | ||||
| -rw-r--r-- | pages/shop/cart/add.fnl | 53 | ||||
| -rw-r--r-- | pages/shop/cart/remove.fnl | 9 | ||||
| -rw-r--r-- | pages/shop/index.fnl | 120 | ||||
| -rw-r--r-- | pages/shop/order.fnl | 59 | ||||
| -rw-r--r-- | pages/shop/order/_id.fnl | 35 | ||||
| -rw-r--r-- | pages/shop/order/index.fnl | 72 | ||||
| -rw-r--r-- | pages/shop/order/list.fnl | 72 | ||||
| -rw-r--r-- | pages/shop/order/state.fnl | 18 | ||||
| -rw-r--r-- | pages/shop/success.fnl | 15 | ||||
| -rwxr-xr-x | run.sh | 11 | ||||
| -rw-r--r-- | secrets.fnl.example | 4 | ||||
| -rw-r--r-- | shop.fnl | 121 | ||||
| -rw-r--r-- | static/style.css | 97 | ||||
| -rw-r--r-- | templates.fnl | 127 | ||||
| -rw-r--r-- | test.fnl | 112 |
27 files changed, 746 insertions, 453 deletions
@@ -1,2 +1,3 @@ /var/db.* /static/files/* +secrets.fnl @@ -10,7 +10,7 @@ vim.api.nvim_create_autocmd({"BufEnter", "BufWinEnter"}, { pattern = {"*.fnl"}, callback = function() vim.repl.fennel.cmd = {"go", "run", "-tags=fts5,jit", "../.", - "-n", "1", "-D", "main.lua", "bin/serve.fnl"} + "-n", "1", "-D", "bin/serve.fnl"} vim.repl.fennel.filters = {} end, }) diff --git a/bin/serve.fnl b/bin/serve.fnl index 359bfd9..0948a83 100644 --- a/bin/serve.fnl +++ b/bin/serve.fnl @@ -1,17 +1,12 @@ (local lib (require :lib)) -(when _G.unpack +( when _G.unpack (tset table :unpack _G.unpack)) (fn _G.must [...] (local (ok? result) ...) (if ok? result (error result))) -(fn _G.pp [...] - (local args (table.pack ...)) - (for [i 1 args.n] - (print (fennel.view (. args i))))) - (fn _G.reload [module] (local old (require module)) (tset package :loaded module nil) @@ -90,8 +85,10 @@ id TEXT PRIMARY KEY, creation_time TEXT NOT NULL, placement_time TEXT, - first_name TEXT, - contact TEXT + state TEXT NOT NULL DEFAULT 'cart', + name TEXT, + contact TEXT, + consent BOOLEAN ); CREATE TABLE IF NOT EXISTS order_lines( @@ -174,9 +171,24 @@ (test "shop/xyz/edit" "shop/_product/edit" {:_product "xyz"}))) (test-match-route) - (local routes (scan-routes "pages")) +(fn authenticate-request [db request] + (let [cookies-header (. request.headers :Cookie) + cookies (if cookies-header (lib.parse-values cookies-header) {}) + session-id cookies.auth] + (if (not (lib.empty? session-id)) + (let [sessions + (_G.must + (luna.db.query-assoc + db + "SELECT id FROM auth_sessions + WHERE id = ? + AND expires_at > STRFTIME('%Y-%m-%d %H:%M:%S', DATETIME('now'))" + [session-id]))] + (< 0 (# sessions))) + false))) + (fn router [request] (if (and (lib.ends-with? request.path "/") (~= request.path "/")) @@ -185,16 +197,13 @@ (tset request :params params) (if (and (= (type handler) "table") handler.render) (let [(code headers content) - (handler.render request db (lib.authenticate-request db request))] + (handler.render request db (authenticate-request db request))] (values code headers (.. "<!DOCTYPE html>\n" content))) (values 404 {:content-type "text/html"} "not found"))))) -(_G.must - (luna.router.route "/" router)) +(_G.must (luna.router.route "/" router)) (_G.must (luna.router.static "GET /static" "static/" "")) -(_G.must - (luna.router.static "GET /static/files" - "static/files" "")) +(_G.must (luna.router.static "GET /static/files" "static/files" "")) (when (= 0 (# (_G.must (luna.db.query db "SELECT name FROM users LIMIT 1" [])))) (let [password (_G.must (luna.crypto.random-string 20)) @@ -23,6 +23,12 @@ {:value "piece" :label "Штучный товар"} {:value "pieces" :label "Разлом"}]) +(local order-state + [{:value "cart" :label "Корзина"} + {:value "placed" :label "Размещен"} + {:value "done" :label "Выполнен"} + {:value "canceled" :label "Отменен"}]) + (fn label [dict value] (var result nil) (each [_ item (ipairs dict) &until result] @@ -30,4 +36,4 @@ (set result item.label))) result) -{: product-type : tea-packaging : tea-season : label} +{: product-type : tea-packaging : tea-season : order-state : label} @@ -245,9 +245,11 @@ (table.concat (icollect [_ field (ipairs group.fields)] (field.html (. data field.name) (. errors field.name))))])) - (HTML [:button {:type "submit"} "Сохранить"])))])) + (HTML [:button {:type "submit" + :onclick "this.disabled = true; this.form.submit();"} + "Сохранить"])))])) -(fn convert-values-from-html [form data db] +(fn html-form->data [form data db] (each [_ group (ipairs form)] (each [_ field (ipairs group.fields)] (local value (. data field.name)) @@ -336,7 +338,7 @@ : file-input : select-input : render-form - : convert-values-from-html + : html-form->data : validate-form : form-insert-sql-statement : form-update-sql-statement} @@ -1,3 +1,5 @@ +(local secrets (require :secrets)) + (fn now [] (os.date "%Y-%m-%d %H:%M:%S")) @@ -81,11 +83,6 @@ final-name)) -(fn order-id [request] - (let [cookies-header (. request.headers :Cookie) - cookies (if cookies-header (parse-values cookies-header) {})] - cookies.order)) - (fn with-tx [db f] (let [tx (_G.must (luna.db.begin db))] (local (ok? result) (pcall f tx)) @@ -151,52 +148,79 @@ k1 v1 (pairs t1) &until (not res)] (and (. t2 k1) (= v1 (. t2 k1)))))) -(fn basket [db order-id] - (local items - (_G.must - (luna.db.query-assoc - db - "SELECT order_lines.id, - products.name, - products.title, - products.price_per AS \"price-per\", - STRING_AGG(product_images.name, ',') AS \"images\", - order_lines.quantity - FROM order_lines - INNER JOIN products ON products.name = order_lines.product_name - LEFT JOIN product_images ON products.name = product_images.product_name - WHERE order_lines.order_id = ? - GROUP BY order_lines.id - ORDER BY product_images.position" - [order-id]))) - (if (and (. items 1) (not (empty-table? (. items 1)))) - (icollect [_ item (ipairs items)] - (do - (when (. item :images) - (tset item :images (split (. item :images) ","))) - item)) - [])) - (fn string->number [str] (if str (tonumber (pick-values 1 (str:gsub "[^0-9.]" ""))) nil)) -(fn authenticate-request [db request] - (let [cookies-header (. request.headers :Cookie) - cookies (if cookies-header (parse-values cookies-header) {}) - session-id cookies.auth] - (if (not (empty? session-id)) - (let [sessions - (_G.must - (luna.db.query-assoc - db - "SELECT id FROM auth_sessions - WHERE id = ? - AND expires_at > STRFTIME('%Y-%m-%d %H:%M:%S', DATETIME('now'))" - [session-id]))] - (< 0 (# sessions))) - false))) +(fn insert [str substr pos] + (.. (str:sub 1 pos) substr (str:sub (+ 1 pos)))) + +(fn format-price [price] + (var price-str (tostring price)) + (local dot-position (price-str:find "%.")) + (local price-len (if dot-position + (- (pick-values 1 dot-position) 1) + (# price-str))) + (var cursor (- price-len 3)) + (while (< 0 cursor) + (set price-str (insert price-str " " cursor)) + (set cursor (- cursor 3))) + price-str) + +(fn group-by [lines fields] + (fn contains? [array needle] + (var found? false) + (each [_ v (ipairs array) &until found?] + (when (= v needle) (set found? true))) + found?) + + (if (= 0 (# lines)) + [] + (do + (local result []) + (var grouping-line (. lines 1)) + (tset grouping-line :rest []) + + (each [_ line (ipairs lines)] + (each [_ field (ipairs fields) &until (= line grouping-line)] + (when (~= (. grouping-line field) (. line field)) + (do + (table.insert result grouping-line) + (set grouping-line line) + (tset grouping-line :rest [])))) + + (var rest {}) + (each [key value (pairs line)] + (when (not (contains? fields key)) + (tset rest key value))) + (table.insert grouping-line.rest rest)) + + (table.insert result grouping-line) + result))) + +(fn encode-url-values [vals] + (table.concat + (accumulate [result [] k v (pairs vals)] + (do + (table.insert result (.. k "=" (_G.must (luna.http.encode-url v)))) + result)) + "&")) + +(fn notify [message] + (local tg-api-url + (.. "https://api.telegram.org/bot" secrets.telegram-bot-token "/sendMessage")) + + ;; TODO: test for non-200 responses and log them. + (local response + (_G.must + (luna.http.request + "POST" tg-api-url + {:Content-Type "application/x-www-form-urlencoded"} + (encode-url-values + {:chat_id secrets.telegram-notification-user-id + :text message + :parse_mode "html"}))))) {: improve-typography : starts-with? @@ -206,7 +230,6 @@ : trim-right : file-exists? : parse-values - : order-id : handle-upload : with-tx : append @@ -217,6 +240,8 @@ : merge : empty? : now - : basket : string->number - : authenticate-request} + : format-price + : group-by + : encode-url-values + : notify} @@ -1,10 +1,11 @@ -(local entity-replacements {"&" "&" ; must be first! - "<" "<" - ">" ">" - "\"" """}) +(local entity-replacements + {"&" "&" ; must be first! + "<" "<" + ">" ">" + "\"" """}) (local entity-search - (.. "[" (table.concat (icollect [k (pairs entity-replacements)] k)) "]")) + (.. "[" (table.concat (icollect [k (pairs entity-replacements)] k)) "]")) (fn escape-html [s] (assert (= (type s) :string)) diff --git a/pages/auth.fnl b/pages/auth.fnl index 3afb517..1f40cab 100644 --- a/pages/auth.fnl +++ b/pages/auth.fnl @@ -13,11 +13,11 @@ (set data.password nil) [(HTML - [:div {:class "side"} + [:aside {} (templates.header "/auth")]) (HTML [:section {:class "content"} - [:div {:class "mb-1"} [:a {:href "/"} "⟵ Обратно на главную"]] + [:div {:class "back"} [:a {:href "/"} "⟵ Обратно на главную"]] [:h2 {} "Войти"] (forms.render-form auth-form data errors)])]) @@ -57,7 +57,7 @@ db name (os.date "%Y-%m-%d %H:%M:%S" next-week)) cookie-expires (os.date "%a, %d %b %Y %H:%M:%S GMT" next-week)] (values 302 {:Location "/shop" - :Set-Cookie (.. "auth= " session-id "; HttpOnly; SameSite=strict;" + :Set-Cookie (.. "auth=" session-id "; HttpOnly; SameSite=strict;" "Expires=" cookie-expires (if luna.debug? "" "; Secure"))} "")) (values 400 {} diff --git a/pages/index.fnl b/pages/index.fnl index 88cdca7..37ab914 100644 --- a/pages/index.fnl +++ b/pages/index.fnl @@ -19,7 +19,13 @@ "Еженедельное мероприятие: каждую субботу в 15:00 мы собираемся и пьем чай из нашей коллекции. Для посещения необходима запись в комментариях под соответствующим постом в нашей группе. Стоимость 500 рублей - с человека.")}) + с человека.") + + :everytea + (lib.improve-typography + "Мы разработали и поддерживаем сервис для поиска чая и чайной посуды на + популярных сайтах. Полезно, если не хотите искать на отдельных сайтах или + хотите быстро сравнить цены на конкретный чай.")}) (fn pick-gallery-photo [list] (let [chosen (. list (math.random (# list)))] @@ -30,21 +36,26 @@ (fn content [authenticated?] [(HTML - [:div {:class "side mb-2"} + [:aside {} (templates.header "" authenticated?) - [:section {:class "mb-2"} + [:section {} [:h2 {} "Адрес"] [:p {} [:NO-ESCAPE texts.address]]] [:section {} [:h2 {} "Форматы участия"] - [:div {:class "mb-2"} + [:div {:class "mb-1-5"} [:div {:class "mb-1"} [:NO-ESCAPE texts.individual-ceremony]] [:div {} [:a {:href "https://t.me/whitetoadvlad"} "Записаться ⟶"]]] [:div {} [:div {:class "mb-1"} [:NO-ESCAPE texts.weekly-meetings]] [:div {} - [:a {:href "https://t.me/whitetoadtea"} "Подписаться ⟶"]]]]]) + [:a {:href "https://t.me/whitetoadtea"} "Подписаться ⟶"]]]] + [:section {} + [:h2 {} "Агрегатор"] + [:p {} [:NO-ESCAPE texts.everytea]] + [:div {} + [:a {:href "https://everytea.ru"} "everytea.ru ⟶"]]]]) (HTML [:div {:class "content"} [:div {:class "gallery"} diff --git a/pages/shop/_product/edit.fnl b/pages/shop/_product/edit.fnl index a8fd445..3e4f2f0 100644 --- a/pages/shop/_product/edit.fnl +++ b/pages/shop/_product/edit.fnl @@ -48,11 +48,11 @@ (fn content [form data errors authenticated?] [(HTML - [:div {:class "side"} + [:aside {} (templates.header "/shop" authenticated?)]) (HTML [:div {:class "content"} - [:div {:class "mb-1"} + [:div {:class "back"} [:a {:href (.. "/shop/" data.name)} "⟵ Обратно к товару"]] [:h2 {} "Редактировать товар"] (forms.render-form form data errors)])]) @@ -61,7 +61,7 @@ (if (not authenticated?) (values 302 {:Location "/shop"} "") (if request.form - (let [data (forms.convert-values-from-html product-form request.form db) + (let [data (forms.html-form->data product-form request.form db) errors (forms.validate-form product-form data) has-errors? (not (lib.empty-table? errors))] (if has-errors? diff --git a/pages/shop/_product/index.fnl b/pages/shop/_product/index.fnl index cb451b7..f8b38f7 100644 --- a/pages/shop/_product/index.fnl +++ b/pages/shop/_product/index.fnl @@ -1,7 +1,7 @@ (import-macros {:compile-html HTML} :macros) (local templates (require :templates)) -(local dicts (require :dicts)) (local lib (require :lib)) +(local shop (require :shop)) (fn text->html [text] (assert (= (type text) "string")) @@ -41,44 +41,32 @@ [name])) 1)) -(fn content [product authenticated?] +(fn content [product basket authenticated?] + (local redirect-url (.. "/shop/" product.name)) (local images []) (for [i 1 5] (table.insert images (. product (.. "image" i)))) [(HTML - [:div {:class "side"} - (templates.header "/shop" authenticated?)]) + [:aside {} + (templates.header "/shop" authenticated?) + (if (< 0 (# basket)) (templates.basket basket redirect-url) "")]) (HTML [:div {:class "content"} - [:div {:class "mb-1"} [:a {:href "/shop"} "⟵ Обратно к списку"]] + [:div {:class "back"} [:a {:href "/shop"} "⟵ Обратно к списку"]] [:div {:class "product-page-layout"} - [:article {} + [:section {} [:h2 {:class "product-page-title"} product.title] (if authenticated? (HTML - [:div {:class "mb-1" :style "margin-top: -0.25rem;"} + [:div {:class "mb-1" :style "margin-top: -0.5rem;"} [:a {:href (.. "/shop/" product.name "/edit")} "% Редактировать"]]) "") - [:div {:class "mb-0-5" :style "font-style: italic;"} - (or (dicts.label dicts.product-type product.type) product.type) ", " - (if (not (lib.empty? product.year)) - (HTML [:span {} [:NO-ESCAPE (.. product.year " год, ")]]) - "") - (if (not (lib.empty? product.volume)) - (HTML [:span {} [:NO-ESCAPE (.. product.volume " мл., ")]]) - "") - (if (not (lib.empty? product.region)) (.. product.region ", ") "") - (if (= product.packaging "piece") - (HTML [:strong {} product.price-per "₽"]) - (HTML [:span {} - [:strong {} [:NO-ESCAPE - (* 50 product.price-per) - "₽ за 50 грамм "]] - [:NO-ESCAPE "(" product.price-per - "₽ за 1 грамм)"]]))] + (templates.add-to-basket-form product "mb-0-5" redirect-url) + (templates.product-overview product "mb-0-5") + [:div {:class "mb-1" :style "font-style: italic;"} product.short-description] (let [link (.. "/static/files/" product.image1)] @@ -105,9 +93,11 @@ (HTML [:img {:class "product-page-img" :src (.. link "-thumbnail.jpg")}]))]))))]]])]) (fn render [request db authenticated?] - (let [product (find-product db request.params._product)] + (let [product (find-product db request.params._product) + order-id (shop.order-id request) + basket (if order-id (shop.basket db order-id) [])] (if (and product (or product.published authenticated?)) - (values 200 {} (templates.base (content product authenticated?))) + (values 200 {} (templates.base (content product basket authenticated?))) (values 404 {} "not found")))) {: render} diff --git a/pages/shop/add.fnl b/pages/shop/add.fnl index b5fa8de..01b83d6 100644 --- a/pages/shop/add.fnl +++ b/pages/shop/add.fnl @@ -68,12 +68,11 @@ (fn content [form data errors authenticated?] [(HTML - [:div {:class "side"} + [:aside {} (templates.header "/shop" authenticated?)]) (HTML [:div {:class "content"} - [:div {:class "mb-1"} - [:a {:href "/shop"} "⟵ Обратно к списку"]] + [:div {:class "back"} [:a {:href "/shop"} "⟵ Обратно к списку"]] [:h2 {} "Добавить товар"] (forms.render-form form data errors)])]) @@ -81,7 +80,7 @@ (if (not authenticated?) (values 302 {:Location "/shop"} "") (if request.form - (let [data (forms.convert-values-from-html product-form request.form db) + (let [data (forms.html-form->data product-form request.form db) errors (forms.validate-form product-form request.form) has-errors? (not (lib.empty-table? errors))] (if has-errors? diff --git a/pages/shop/cart/add.fnl b/pages/shop/cart/add.fnl index 36e3e41..53366b4 100644 --- a/pages/shop/cart/add.fnl +++ b/pages/shop/cart/add.fnl @@ -1,38 +1,33 @@ (local lib (require :lib)) - -(fn create-order [db] - (let [id (_G.must (luna.crypto.random-string 64))] - (_G.must - (luna.db.exec - db "INSERT INTO orders (id, creation_time) VALUES (?, ?)" - [id (lib.now)])) - id)) - -(fn create-order-line [db order-id name quantity] - (_G.must - (luna.db.exec - db - "INSERT INTO order_lines (order_id, product_name, quantity) VALUES (?, ?, ?)" - [order-id name quantity]))) +(local shop (require :shop)) (fn render [request db] (if (= request.method "POST") (do - (var order-id (lib.order-id request)) - (var headers - (if (not order-id) - (do - (set order-id (create-order db)) - {:Set-Cookie (.. "order= " order-id "; HttpOnly; SameSite=strict" - (if luna.debug? "" "; Secure"))}) - {})) + (var order-id (shop.order-id request)) + (var headers {}) + + (when (not order-id) + (local next-week + (os.date "%a, %d %b %Y %H:%M:%S GMT" (+ (os.time) (* 60 60 24 7)))) + (set order-id (shop.create-order db)) + (set headers + {:Set-Cookie (.. "order=" order-id "; Path=/; " + "Expires=" next-week "; " + "HttpOnly; SameSite=strict" + (if luna.debug? "" "; Secure"))})) - (if (and order-id request.body) - (let [body-values (lib.parse-values request.body)] - (create-order-line db order-id body-values.name body-values.quantity) - (tset headers :Location "/shop") - (values 302 headers "")) - (values 400 {} "bad body"))) + (let [body-values (lib.parse-values request.body)] + (if (and order-id request.body + (< 0 (tonumber body-values.quantity))) + (do + (shop.create-order-line + db order-id body-values.name body-values.quantity) + (tset headers :Location (_G.must + (luna.http.decode-url + body-values.redirect-url))) + (values 302 headers "")) + (values 400 {} "bad body")))) (values 404 {} "not found"))) {: render} diff --git a/pages/shop/cart/remove.fnl b/pages/shop/cart/remove.fnl index d5e3531..0cdc417 100644 --- a/pages/shop/cart/remove.fnl +++ b/pages/shop/cart/remove.fnl @@ -1,16 +1,13 @@ (local lib (require :lib)) +(local shop (require :shop)) (fn render [request db] (if (= request.method "POST") - (let [order-id (lib.order-id request)] + (let [order-id (shop.order-id request)] (if (and order-id request.body) (do (local body-values (lib.parse-values request.body)) - (_G.must - (luna.db.exec - db - "DELETE FROM order_lines WHERE id = ? AND order_id = ?" - [body-values.id order-id])) + (shop.delete-order-line db body-values.id) (values 302 {:Location (_G.must diff --git a/pages/shop/index.fnl b/pages/shop/index.fnl index bd3e88b..dff486c 100644 --- a/pages/shop/index.fnl +++ b/pages/shop/index.fnl @@ -1,5 +1,6 @@ (import-macros {:compile-html HTML} :macros) (local lib (require :lib)) +(local shop (require :shop)) (local dicts (require :dicts)) (local templates (require :templates)) @@ -17,8 +18,10 @@ products.short_description as \"short-description\", products.price_per AS \"price-per\", products.volume, + products.stock, products.packaging, products.type, + products.region, products.image1, products.image2, products.image3, @@ -28,27 +31,8 @@ where " ORDER BY products.position") []))) -(fn quantity-steps [stock step] - (assert (< 0 step) "step must be greater than 0") - - (var result []) - (var first (math.min stock step)) - (while (<= first stock) - (table.insert result first) - (set first (+ first step))) - result) - (fn item-template [product] (local item-url (.. "/shop/" product.name)) - ;; (var quantity-options []) - ;; (if (< 0 product.stock) - ;; (each [_ q (ipairs (quantity-steps product.stock 50))] - ;; (table.insert quantity-options - ;; (HTML - ;; [:option {:value (tostring q)} - ;; (.. q " грамм за " (* product.price-per q) "₽")]))) - ;; (table.insert quantity-options (HTML [:option {:value "0"} "Товар закончился"]))) - (local images []) (for [i 2 5] (table.insert images (. product (.. "image" i)))) @@ -74,47 +58,33 @@ :style (.. "z-index: " (+ idx 2) ";" "width: calc(100% / " (# without-videos) ");" "left: calc(100% / " (# without-videos) " * " (- idx 1) ")")}]))))]] - [:a {:href item-url} [:h3 {:class "shop-item-title"} product.title]] - [:div {:style "font-style: italic; margin-bottom: 0.25rem;"} - (or (dicts.label dicts.product-type product.type) product.type) ", " - (if (not (lib.empty? product.volume)) - (HTML [:span {} [:NO-ESCAPE (.. product.volume " мл., ")]]) - "") - (if (= product.packaging "piece") - (HTML [:strong {} product.price-per "₽"]) - (HTML [:strong {} (* 50 product.price-per) "₽ за 50 гр. "]))] - ;; [:div {:class "shop-item-price"} - ;; [:form {:method "POST"} - ;; [:input {:type "hidden" :name "name" :value product.name}] - ;; [:select {:name "quantity"} (table.concat quantity-options)] - ;; [:button {:type "submit"} "Добавить"]]] + [:a {:href item-url} + [:h3 {:class "shop-item-title"} product.title]] + [:div {:class "shop-item-price"} + (templates.add-to-basket-form product "" "/shop")] + (templates.product-overview product "mb-0-25 font-size-0-875") [:div {} product.short-description]])) -(fn content [db basket basket-total authenticated?] +(fn content [db basket authenticated?] [(HTML - [:div {:class "side"} + [:aside {} (templates.header "/shop" authenticated?) - (if (< 0 (# basket)) - (HTML - [:article {:class "article"} - [:h2 {} "Корзина"] - [:div {} - (table.concat - (icollect [_ item (ipairs basket)] - (templates.basket-item item "/shop")))] - [:div {} "~~~"] - [:div {:class "basket-total"} (.. "Итого: " basket-total "₽")] - [:a {:href "/shop/order"} "Оформить заказ"]]) - "")]) + (if (< 0 (# basket)) (templates.basket basket "/shop") "") + [:section {} + [:h2 {} "Условия"] + [:p {} ""]]]) (HTML [:div {:class "content"} - [:div {:class "mb-1"} [:a {:href "/"} "⟵ Обратно на главную"]] - [:h2 {:class "product-page-title mb-1"} - "Магазин" - (if authenticated? - (HTML [:a {:style "font-size: 1rem; margin-left: 0.75rem;" - :href (.. "/shop/add")} "+ Добавить"]) - "")] + [:div {:class "back"} [:a {:href "/"} "⟵ Обратно на главную"]] + [:h2 {:class "product-page-title"} "Магазин"] + (if authenticated? + (HTML + [:div {:class "mb-1" :style "margin-top: -0.5rem"} + [:a {:style "white-space: nowrap" + :href (.. "/shop/add")} "+ Добавить"] + [:a {:style "white-space: nowrap; margin-left: 1rem;" + :href (.. "/shop/order/list")} "☰ Список заказов"]]) + "") [:div {:class "shop-items"} (let [products (all-products db authenticated?)] (if (< 0 (# products)) @@ -123,45 +93,9 @@ (item-template v))) (HTML [:em {} "Пока что здесь ничего нет!"])))]])]) -(fn create-order [db] - (let [id (_G.must (luna.crypto.random-string 64))] - (_G.must - (luna.db.exec - db "INSERT INTO orders (id, creation_time) VALUES (?, ?)" - [id (lib.now)])) - id)) - -(fn create-order-line [db order-id name quantity] - (_G.must - (luna.db.exec - db - "INSERT INTO order_lines (order_id, product_name, quantity) VALUES (?, ?, ?)" - [order-id name quantity]))) - (fn render [request db authenticated?] - (let [order-id (lib.order-id request) - basket (if order-id (lib.basket db order-id) []) - basket-total (accumulate [sum 0 _ v (ipairs basket)] - (+ sum (* v.quantity v.price-per)))] - (if (= request.method "POST") - (do - (var order-id (lib.order-id request)) - (var headers - (if (not order-id) - (do - (set order-id (create-order db)) - {:Set-Cookie (.. "order= " order-id "; HttpOnly; SameSite=strict" - (if luna.debug? "" "; Secure"))}) - {})) - - (if (and order-id request.body) - (let [body-values (lib.parse-values request.body)] - (create-order-line db order-id body-values.name body-values.quantity) - (tset headers :Location "/shop") - (values 302 headers "")) - (values 400 {} "bad body"))) - (values - 200 {} - (templates.base (content db basket basket-total authenticated?)))))) + (let [order-id (shop.order-id request) + basket (if order-id (shop.basket db order-id) [])] + (values 200 {} (templates.base (content db basket authenticated?))))) {: render} diff --git a/pages/shop/order.fnl b/pages/shop/order.fnl deleted file mode 100644 index 50e12da..0000000 --- a/pages/shop/order.fnl +++ /dev/null @@ -1,59 +0,0 @@ -(import-macros {:compile-html HTML} :macros) -(local lib (require :lib)) -(local templates (require :templates)) - -(fn content-template [db basket basket-total] - [(HTML - [:div {:class "side"} - (templates.header "/shop/order")]) - (HTML - [:div {:class "content"} - (if (< 0 (# basket)) - [:section {} - [:h2 {} "Состав заказа"] - [:div {} - (table.unpack - (icollect [_ item (ipairs basket)] - (templates.basket-item item "/shop/order")))] - [:div {} "~~~"] - [:div {:class "basket-total"} (.. "Итого: " basket-total "₽")]] - "") - [:section {} - [:h2 {} "Данные для связи"] - [:form {:class "form" :method "POST"} - [:div {:class "form-row"} - [:label {:for "name"} "Имя"] - [:input {:type "text" :id "name" :name "name" :required "required"}]] - [:div {:class "form-row"} - [:label {:for "contact"} "Телеграм или Email для связи"] - [:input {:type "text" :id "contact" :name "contact" :required "required"}]] - [:div {:class "form-row"} - [:input {:type "checkbox" :id "everything-is-correct" - :name "everything-is-correct" :required "required"}] - [:label {:for "everything-is-correct"} "Данные заказа верны"]] - [:div {:class "form-row"} - [:input {:type "checkbox" :id "agree-to-conditions" - :name "agree-to-conditions" :required "required"}] - [:label {:for "agree-to-conditions"} "Согласен с условиями"]] - [:button {:type "submit"} "Оформить заказ"]]]])]) - -(fn place-order [db order-id form] - (_G.must - (luna.db.exec db - "UPDATE orders SET placement_time = ?, first_name = ?, contact = ?" - [(lib.now) form.name form.contact]))) - -(fn render [request db] - (let [order-id (lib.order-id request) - basket (if order-id (lib.basket db order-id) []) - basket-total (accumulate [sum 0 _ v (ipairs basket)] - (+ sum (* v.quantity v.price-per)))] - (if (= request.method "POST") - (do - (place-order db order-id (lib.parse-values request.body)) - (values 302 {:Location "/shop/success"} "")) - (if (< 0 (# basket)) - (values 200 {} (templates.base (content-template db basket basket-total))) - (values 302 {:Location "/shop"} ""))))) - -{: render} diff --git a/pages/shop/order/_id.fnl b/pages/shop/order/_id.fnl new file mode 100644 index 0000000..5c929a3 --- /dev/null +++ b/pages/shop/order/_id.fnl @@ -0,0 +1,35 @@ +(import-macros {:compile-html HTML} :macros) +(local templates (require :templates)) +(local shop (require :shop)) +(local lib (require :lib)) +(local dicts (require :dicts)) + +(local texts + {:thanks + (lib.improve-typography + "Мы получили оповещение о заказе. В ближайшее время свяжемся с вами + для уточнения деталей.")}) + +(fn content [order-lines authenticated?] + (local total (accumulate [sum 0 _ v (ipairs order-lines)] + (+ sum (* v.quantity v.price-per)))) + + [(HTML [:aside {} + (templates.header "/shop/order" authenticated?)]) + (HTML + [:div {:class "content"} + [:div {:class "back"} [:a {:href "/shop"} "⟵ Обратно к списку"]] + [:section {} + [:h2 {} "Спасибо за заказ!"] + [:p {} texts.thanks] + (templates.order-lines order-lines) + [:div {} "—"] + [:div {} [:strong {} (.. "Итого: " (lib.format-price total) "₽")]]]])]) + +(fn render [request db authenticated?] + (let [order-lines (shop.basket db request.params._id)] + (if (< 0 (# order-lines)) + (values 200 {} (templates.base (content order-lines authenticated?))) + (values 302 {:Location "/shop"} "")))) + +{: render} diff --git a/pages/shop/order/index.fnl b/pages/shop/order/index.fnl new file mode 100644 index 0000000..7d25a40 --- /dev/null +++ b/pages/shop/order/index.fnl @@ -0,0 +1,72 @@ +(import-macros {:compile-html HTML} :macros) +(local forms (require :forms)) +(local lib (require :lib)) +(local shop (require :shop)) +(local templates (require :templates)) + +(local order-form + [{:title "" + :fields [ + (forms.text-input "name" "Как к вам обращаться?" true) + (forms.text-input "contact" "Телеграм или E-mail для связи" true) + (forms.checkbox-input "correct-order" "Данные заказа верны" true) + (forms.checkbox-input "consent" + (.. + "Я даю согласие ИП «Горенкин Владислав Константинович» (ИНН ...)" + " на хранение и обработку предоставленных персональных данных для уточнения деталей заказа.") + true)]}]) + +(fn content-template [db basket data errors] + [(HTML + [:aside {} + (templates.header "/shop/order") + (if (< 0 (# basket)) (templates.basket basket "/shop/order") "")]) + (HTML + [:div {:class "content"} + [:div {:class "back"} [:a {:href "/shop"} "⟵ Обратно к списку"]] + [:section {} + [:h2 {} "Оформление заказа"] + (forms.render-form order-form data errors)]])]) + +(fn check-stocks [db basket] + (var error nil) + + (each [_ line (ipairs basket) &until error] + (local product + (. (_G.must + (luna.db.query-assoc + db "SELECT title, stock FROM products WHERE name = ?" + [line.name])) + 1)) + (when (< (- product.stock line.quantity) 0) + (set error (.. "К сожалению, товар «" product.title "» закончился." + " Пожалуйста, уберите его из корзины и попробуйте оформить заказ снова.")))) + + error) + +(fn render [request db] + (let [order-id (shop.order-id request) + basket (if order-id (shop.basket db order-id) [])] + (if (= request.method "POST") + (let [data (forms.html-form->data order-form request.form) + stock-error (check-stocks db basket)] + (if (not stock-error) + (do + (shop.place-order db order-id data.name data.contact data.consent) + (lib.notify + (.. "Новый заказ " + "<a href=\"https://whitetoad.ru/shop/order/" order-id "\">" + order-id + "</a> от <b>" data.name "</b>.\n\n" + "<a href=\"https://whitetoad.ru/shop/order/list\">Список заказов</a>")) + ;; redirect and clear order cookie + (values 302 {:Location (.. "/shop/order/" order-id) + :Set-Cookie "order=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT"} + "")) + (values 400 {} + (templates.base (content-template db basket data {:consent stock-error}))))) + (if (< 0 (# basket)) + (values 200 {} (templates.base (content-template db basket {} {}))) + (values 302 {:Location "/shop"} ""))))) + +{: render} diff --git a/pages/shop/order/list.fnl b/pages/shop/order/list.fnl new file mode 100644 index 0000000..2f64f7b --- /dev/null +++ b/pages/shop/order/list.fnl @@ -0,0 +1,72 @@ +(import-macros {:compile-html HTML} :macros) +(local lib (require :lib)) +(local templates (require :templates)) +(local dicts (require :dicts)) + +(fn all-orders [db] + (lib.group-by + (_G.must + (luna.db.query-assoc db + "SELECT orders.id, + orders.placement_time AS \"placement-time\", + orders.name \"contact-name\", + orders.contact, + orders.state, + order_lines.id AS \"order-line-id\", + order_lines.quantity, + products.image1, + products.name, + products.type, + products.packaging, + products.title, + products.price_per \"price-per\" + FROM orders + INNER JOIN order_lines ON orders.id = order_lines.order_id + INNER JOIN products ON products.name = order_lines.product_name + WHERE orders.state != 'cart' + ORDER BY orders.placement_time DESC, orders.id" + {})) + [:id :placement-time :state :contact-name :contact])) + +(fn content [orders authenticated?] + [(HTML + [:aside {} + (templates.header "/shop/orders" authenticated?)]) + (HTML + [:section {:class "content"} + [:div {:class "mb-1"} [:a {:href "/"} "⟵ Обратно к списку"]] + [:h2 {:class "product-page-title"} "Список заказов"] + + (table.concat + (icollect [_ order (ipairs orders)] + (let [total (accumulate [sum 0 _ v (ipairs order.rest)] + (+ sum (* v.quantity v.price-per)))] + (HTML + [:section {:class "mb-2 font-size-0-875" } + [:h3 {:class "mb-0-25"} [:a {:href (.. "/shop/order/" order.id)} order.id]] + [:div {:class "mb-0-25 d-flex gap-0-25"} + (templates.order-state order.state) + (if (= "placed" order.state) + (HTML + [:form {:action "/shop/order/state" :method "POST"} + [:select {:name "state" :required true} + [:option {:value ""} "Новое состояние"] + [:option {:value "done"} "Выполнен"] + [:option {:value "canceled"} "Отменен"]] + [:input {:type "hidden" :name "id" :value order.id}] + [:button {:type "submit"} "Применить"]]) + "")] + [:em {:class "d-block mb-0-5"} + "Дата: " order.placement-time + " / " "контакт: " order.contact + " / " "как обращаться: " order.contact-name] + (templates.order-lines order.rest) + [:div {} "—"] + [:div {} [:strong {} (.. "Итого: " (lib.format-price total) "₽")]]]))))])]) + +(fn render [request db authenticated?] + (if authenticated? + (values 200 {} (templates.base (content (all-orders db) authenticated?))) + (values 302 {:Location "/"} ""))) + +{: render} diff --git a/pages/shop/order/state.fnl b/pages/shop/order/state.fnl new file mode 100644 index 0000000..1ff1ca9 --- /dev/null +++ b/pages/shop/order/state.fnl @@ -0,0 +1,18 @@ +(local lib (require :lib)) +(local shop (require :shop)) + +(fn render [request db authenticated?] + (if (and (= request.method "POST") authenticated?) + (let [vals (lib.parse-values request.body)] + (if (and vals vals.id + vals.state (or (= vals.state "done") + (= vals.state "canceled"))) + (do + (if (= "done" vals.state) + (shop.finish-order db vals.id) + (shop.cancel-order db vals.id)) + (values 302 {:Location "/shop/order/list"} "")) + (values 400 {} "bad body"))) + (values 404 {} "not found"))) + +{: render} diff --git a/pages/shop/success.fnl b/pages/shop/success.fnl deleted file mode 100644 index b28f84d..0000000 --- a/pages/shop/success.fnl +++ /dev/null @@ -1,15 +0,0 @@ -(import-macros {:compile-html HTML} :macros) -(local templates (require :templates)) - -(tset _G :package :loaded "pages.shop.success" nil) - -(fn content [] - [(HTML [:div {:class "side"} - (templates.header "/shop/order")]) - (HTML [:div {:class "content"} - "Спасибо за заказ!"])]) - -(fn render [] - (values 200 {} (templates.base (content)))) - -{: render} @@ -8,12 +8,17 @@ usage () { } serve () { - echo "running jit" + echo "running in jit" go run -tags jit ../. -n ${1:-1} bin/serve.fnl } +debug () { + echo "running debug version in jit" + go run -tags jit ../. -n ${1:-1} -D bin/serve.fnl +} + deploy () { - git stash -u + # git stash -u scp -r bin root@everytea.ru:~/whitetoad.ru/ scp -r pages root@everytea.ru:~/whitetoad.ru/ scp -r etc root@everytea.ru:~/whitetoad.ru/ @@ -25,7 +30,7 @@ deploy () { scp dicts.fnl root@everytea.ru:~/whitetoad.ru/ scp templates.fnl root@everytea.ru:~/whitetoad.ru/ ssh root@everytea.ru -- systemctl restart whitetoad - git stash pop + # git stash pop } cmd="$1" diff --git a/secrets.fnl.example b/secrets.fnl.example new file mode 100644 index 0000000..5aba8b1 --- /dev/null +++ b/secrets.fnl.example @@ -0,0 +1,4 @@ +{;; use @BotFather to set up the bot. + :telegram-bot-token "..." + ;; numerical user id, use @userinfobot. + :telegram-notification-user-id "..."} diff --git a/shop.fnl b/shop.fnl new file mode 100644 index 0000000..fea398e --- /dev/null +++ b/shop.fnl @@ -0,0 +1,121 @@ +(local lib (require :lib)) + +(fn create-order [db] + (let [id (_G.must (luna.crypto.random-string 16))] + (_G.must + (luna.db.exec + db "INSERT INTO orders (id, creation_time) VALUES (?, ?)" + [id (lib.now)])) + id)) + +;; FIXME: prone to race conditions +(fn place-order [db id name contact consent] + (local current-state + (_G.must + (luna.db.query db "SELECT state FROM orders WHERE id = ?" [id]))) + (when (~= "cart" (. current-state 1 1)) + (error "order must be a cart in order to place it")) + + (lib.with-tx db + (fn [tx] + ;; remove ordered products from stock + (_G.must + (luna.db.exec-tx tx + "UPDATE products + SET stock = stock - (SELECT quantity + FROM order_lines + WHERE product_name = products.name + AND order_id = ?) + WHERE products.name IN (SELECT product_name + FROM order_lines + WHERE order_id = ?)" + [id id])) + (_G.must + (luna.db.exec-tx tx + "UPDATE orders SET placement_time = ?, state = 'placed', name = ?, + contact = ?, consent = ? + WHERE id = ?" + [(lib.now) name contact consent id]))))) + +(fn finish-order [db id] + (local current-state + (_G.must + (luna.db.query db "SELECT state FROM orders WHERE id = ?" [id]))) + (when (~= "placed" (. current-state 1 1)) + (error "order must be placed in order to finish it")) + + (_G.must + (luna.db.exec db "UPDATE orders SET state = 'done' WHERE id = ?" [id]))) + +;; FIXME: prone to race conditions +(fn cancel-order [db id] + (local current-state + (_G.must + (luna.db.query db "SELECT state FROM orders WHERE id = ?" [id]))) + (when (~= "placed" (. current-state 1 1)) + (error "order must be placed in order to cancel it")) + + (lib.with-tx db + (fn [tx] + ;; return stock + (_G.must + (luna.db.exec-tx tx + "UPDATE products + SET stock = stock + (SELECT quantity + FROM order_lines + WHERE product_name = products.name + AND order_id = ?) + WHERE products.name IN (SELECT product_name + FROM order_lines + WHERE order_id = ?)" + [id id])) + (_G.must + (luna.db.exec-tx tx + "UPDATE orders SET state = 'canceled' WHERE id = ?" + [id]))))) + +(fn order-id [request] + (let [cookies-header (. request.headers :Cookie) + cookies (if cookies-header (lib.parse-values cookies-header) {})] + cookies.order)) + +(fn create-order-line [db order-id name quantity] + (_G.must + (luna.db.exec + db + "INSERT INTO order_lines (order_id, product_name, quantity, creation_time) + VALUES (?, ?, ?, ?)" + [order-id name quantity (lib.now)]))) + +(fn delete-order-line [db id] + (_G.must + (luna.db.exec db "DELETE FROM order_lines WHERE id = ?" [id]))) + +(fn basket [db order-id] + (_G.must + (luna.db.query-assoc + db + "SELECT order_lines.id, + products.name, + products.title, + products.price_per AS \"price-per\", + products.packaging, + products.type, + products.short_description AS \"short-description\", + products.image1, + order_lines.quantity + FROM order_lines + INNER JOIN products ON products.name = order_lines.product_name + WHERE order_lines.order_id = ? + GROUP BY order_lines.id + ORDER BY order_lines.creation_time ASC" + [order-id]))) + +{: create-order + : place-order + : finish-order + : cancel-order + : order-id + : create-order-line + : delete-order-line + : basket} diff --git a/static/style.css b/static/style.css index a152c1b..ea8243f 100644 --- a/static/style.css +++ b/static/style.css @@ -131,7 +131,6 @@ a { } nav { - margin-bottom: 2rem; font-size: 1.25rem; } @@ -143,7 +142,7 @@ nav a.active { .container { display: flex; flex-wrap: wrap; - gap: 0.5rem 2rem; + gap: 2rem; } @keyframes logo-rotation { @@ -168,6 +167,7 @@ nav a.active { .logo h1 { font-weight: 900; + margin-bottom: -0.25rem; } .logo::before { @@ -221,14 +221,21 @@ h3 { font-style: normal; } -.side { +aside { max-width: 23rem; + display: flex; + flex-direction: column; + gap: 1.5rem; } -.side h2 { +aside h2 { font-weight: 900; } +aside p:last-child { + margin-bottom: 0; +} + .content { max-width: 56rem; width: 100%; @@ -342,13 +349,12 @@ p { } .shop-item-price { - font-size: 1.25rem; - margin-bottom: 0.5rem; + margin-bottom: 0.25rem; } .shop-item-title { font-size: 1.25rem; - margin-top: 0.25rem;; + margin-top: 0.375rem; margin-bottom: 0.25rem; } @@ -378,39 +384,66 @@ p { display: none; } +.order { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.order-line { + width: 15.5rem; +} + +.order-line, .basket-item { display: flex; gap: 0.75rem; + line-height: 1.2; } .basket-item { margin-top: 1rem; } +.order-line-title, .basket-item-title { + display: block; font-size: 1rem; + margin-bottom: 0.375rem; +} + +.order-line, +.basket-item-image { + font-size: 0; } +.order-line img, .basket-item-image img { - width: 5.25rem; - height: 5.25rem; + width: 5rem; + height: 5rem; object-fit: cover; object-position: center; } +.order-line-price, .basket-item-price { font-size: 1rem; font-style: italic; + margin-bottom: 0.25rem; } .basket-item-remove { - margin-top: 0.25rem; + margin-top: 0.375rem; +} + +.back { + margin-bottom: 1rem; } .form { display: flex; flex-direction: column; - gap: 2rem; + gap: 1rem; max-width: 35rem; } @@ -485,6 +518,32 @@ p { margin-top: 0.25rem; } +.order-state { + padding: 0.125rem 0.375rem; +} + +.order-state-cart { + background-color: #efefef; +} + +.order-state-placed { + background-color: #ffed7a; +} + +.order-state-done { + background-color: #529b57; + color: #ffffff; +} + +.order-state-canceled { + background-color: #e65c5c; + color: #ffffff; +} + +.font-size-0-875 { + font-size: 0.875rem; +} + .d-block { display: block !important; } @@ -497,6 +556,10 @@ p { display: inline-block !important; } +.gap-0-25 { + gap: 0.25rem !important; +} + .gap-1 { gap: 1rem !important; } @@ -505,6 +568,10 @@ p { margin-bottom: 0 !important; } +.mb-0-25 { + margin-bottom: 0.25rem !important; +} + .mb-0-5 { margin-bottom: 0.5rem !important; } @@ -526,12 +593,12 @@ p { margin: 1rem; } - .side { + aside { max-width: 100%; } .content h2 { - font-size: 2.75rem; + font-size: 2.5rem; } .gallery { @@ -567,6 +634,10 @@ p { .product-page-delimiter { display: none; } + + .back { + margin-bottom: 0.25rem; + } } @media screen and (max-width: 41rem) { diff --git a/templates.fnl b/templates.fnl index d18770b..53b5c0c 100644 --- a/templates.fnl +++ b/templates.fnl @@ -1,5 +1,6 @@ (import-macros {:compile-html HTML} :macros) (local lib (require :lib)) +(local dicts (require :dicts)) (fn read-file [file] (with-open [f (io.open file "r")] @@ -32,7 +33,7 @@ :alt "Белая жаба в мультяшном стиле с чайником на голове"}])) (HTML - [:article {:class "article"} + [:section {} [:div {:class "logo"} (if authenticated? (HTML [:img {:class "logo-glasses" :src "/static/glasses.png" @@ -41,7 +42,7 @@ (if (~= current-path "") (HTML [:a {:href "/" :class "d-inline-block"} logo]) logo) - [:h1 {} [:NO-ESCAPE "Чайная<br>«Белая жаба»"]]] + [:h1 {} [:NO-ESCAPE "Чайный клуб<br>«Белая жаба»"]]] [:nav {} [:a {:href "/shop" :class (if (lib.starts-with? current-path "/shop") "active" "")} "магазин"] @@ -51,19 +52,129 @@ [:a {:href "https://vk.com/whitetoadtea"} "вконтакте"]]])) (fn basket-item [item redirect-url] + (local item-link (.. "/shop/" item.name)) + (HTML [:div {:class "basket-item"} [:div {:class "basket-item-image"} - [:img {:src (.. "/static/files/" (. item :images 1)) :alt item.title}]] + [:a {:href item-link :style "font-size: 0;"} + [:img {:src (.. "/static/files/" item.image1) :alt item.title}]]] [:div {} - [:strong {:class "basket-item-title"} item.title] + [:a {:href item-link :class "basket-item-title"} item.title] [:div {:class "basket-item-price"} - (.. item.quantity " грамм за " - (* item.price-per item.quantity) "₽") + (if (= item.packaging :piece) + (.. (lib.format-price (* item.price-per item.quantity)) + "₽ за " item.quantity " шт.") + (.. (lib.format-price (* item.price-per item.quantity)) + "₽ за " item.quantity " гр.")) [:form {:class "basket-item-remove" :method "POST" :action "/shop/cart/remove"} [:input {:type "hidden" :name "redirect-url" :value redirect-url}] [:input {:type "hidden" :name "id" :value (tostring item.id)}] - [:button {:type "submit"} "⨯ убрать из корзины"]]]]])) + [:button {:type "submit"} "⨯ убрать"]]]]])) + +(fn basket [basket redirect-url] + (let [total (accumulate [sum 0 _ v (ipairs basket)] + (+ sum (* v.quantity v.price-per)))] + (HTML + [:section {} + [:h2 {} "Корзина"] + [:div {:class "mb-0-25"} + (table.concat + (icollect [_ item (ipairs basket)] + (basket-item item redirect-url)))] + [:div {:class "mt-0-25"} "—"] + [:div {} [:strong {} (.. "Итого: " (lib.format-price total) "₽")]] + [:a {:href "/shop/order"} "Оформить заказ ⟶"]]))) + +(fn product-overview [product classes] + (local classes (or classes "")) + + (HTML + [:div {:class classes :style "font-style: italic"} + (or (dicts.label dicts.product-type product.type) product.type) ", " + (if (not (lib.empty? product.year)) + (HTML [:span {} [:NO-ESCAPE (.. product.year " год, ")]]) + "") + (if (not (lib.empty? product.volume)) + (HTML [:span {} [:NO-ESCAPE (.. product.volume " мл., ")]]) + "") + (if (not (lib.empty? product.region)) (.. product.region ", ") "") + (if (= product.packaging "piece") + (HTML [:strong {} (lib.format-price product.price-per) "₽"]) + (HTML [:span {} + [:strong {} + [:NO-ESCAPE + (lib.format-price (* 50 product.price-per)) + "₽ за 50 грамм "]] + [:NO-ESCAPE "(" (lib.format-price product.price-per) + "₽ за 1 грамм)"]]))])) + +(fn add-to-basket-form [product classes redirect-url] + (fn quantity-steps [stock step] + (assert (< 0 step) "step must be greater than 0") + (var result []) + (var first (math.min stock step)) + (while (<= first stock) + (table.insert result first) + (set first (+ first step))) + result) + + (var quantity-options []) + (var no-stock? false) + (let [piece? (= product.packaging :piece)] + (if (< 0 product.stock) + (each [_ q (ipairs (quantity-steps product.stock (if piece? 1 50)))] + (table.insert + quantity-options + (HTML + [:option {:value (tostring q)} + (.. (lib.format-price (* product.price-per q)) + "₽ за " q (if piece? " шт." " гр."))]))) + (do + (table.insert quantity-options (HTML [:option {:value "0"} "Товар закончился"])) + (set no-stock? true)))) + + (HTML + [:form {:method "POST" :action "/shop/cart/add" :class classes} + [:input {:type "hidden" :name "name" :value product.name}] + [:input {:type "hidden" :name "redirect-url" :value redirect-url}] + [:select {:name "quantity"} (table.concat quantity-options)] + (if no-stock? "" (HTML [:button {:type "submit"} "Добавить"]))])) + +(fn order-lines [order-lines] + (HTML + [:div {:class "order mb-0-5"} + (table.concat + (icollect [_ item (ipairs order-lines)] + (let [item-link (.. "/shop/" item.name)] + (HTML + [:div {:class "order-line"} + [:div {:class "order-line-image"} + [:a {:href item-link :style "font-size: 0;"} + [:img {:src (.. "/static/files/" item.image1) + :alt item.title}]]] + [:div {} + [:a {:href item-link :class "order-line-title"} item.title] + [:div {:class "order-line-price"} + (if (= item.packaging :piece) + (.. (lib.format-price (* item.price-per item.quantity)) + "₽ за " item.quantity " шт.") + (.. (lib.format-price (* item.price-per item.quantity)) + "₽ за " item.quantity " гр."))] + [:em {:class "font-size-0-875"} + (dicts.label dicts.product-type item.type)]]]))))])) + +(fn order-state [state] + (HTML [:strong + {:class (.. "order-state order-state-" state)} + (dicts.label dicts.order-state state)])) -{: base : header : basket-item} +{: base + : header + : basket-item + : basket + : product-overview + : add-to-basket-form + : order-lines + : order-state} diff --git a/test.fnl b/test.fnl deleted file mode 100644 index ec8812c..0000000 --- a/test.fnl +++ /dev/null @@ -1,112 +0,0 @@ -(import-macros {:compile-html HTML} :macros) - -(fn improve-typography [text] - (var result - (-> text - (string.gsub "(\n|\r)" " ") - (string.gsub "%s+" " "))) - (let [nbsp-replaces ["на" "На" "и" "И" "в" "В" "о" "О" "с" "С" "со" "Со" "до" - "До" "для" "Для" "а" "А" "но" "Но" "на" "На" "я" "Я" "мы" - "Мы" "над" "Над" "под" "Под" "г." "Г." "ул." "Ул." - "д." "Д." "%d+"]] - (each [_ v (ipairs nbsp-replaces)] - (set result - (-> result - (string.gsub (.. "( " v ") ") "%1 ") - (string.gsub (.. "(%s" v ") ") " %1 ") - (string.gsub (.. "^(" v ") ") "%1 "))))) - result) - -(fn header [current-path] - (local logo - (HTML [:img {:class "logo" :src "/static/logo.svg" - :alt "Белая жаба в мультяшном стиле с чайником на голове"}])) - - (HTML [:article {:class "article"} - (if (~= current-path "") (HTML [:a {:href "/"} logo]) logo) - [:h1 {} "Чайная комната «Белая жаба»"] - [:nav {} - [:a {:href "/shop" :class "active"} - "магазин"] - [:span {} "~"] - [:a {:href "https://t.me/whitetoadtea"} - "телеграм"] - [:span {} "~"] - [:a {:href "https://vk.com/whitetoadtea"} - "вконтакте"]]])) - -(local texts { - :address - (improve-typography - "г. Омск, ул. Пушкина, д. 133/9, этаж 2. Вход с крыльца Магнита, дверь - слева, домофон 4") - - :individual-ceremony - (improve-typography - "Индивидуальная чайная церемония: мастер готовит для вас чай на ваш выбор. - О времени встречи договариваемся. Стоимость 1000 рублей с человека, - до 5 человек.") - - :weekly-meetings - (improve-typography - "Еженедельное мероприятие: каждую субботу в 15:00 мы собираемся и пьем - чай из нашей коллекции. Для посещения необходима запись в комментариях - под соответствующим постом в нашей группе в телеграме. Стоимость 500 - рублей с человека.")}) - -(print - (HTML [:div (fn [] {:huemoe nil :hello "world" :required true}) "whatever"])) - -; (macrodebug -; (HTML [:div (fn [] {:hello "world" :required true}) "whatever"])) - -; (macrodebug -; (HTML -; [:div {:class "side"} -; (unpack [])])) - -; (macrodebug -; (HTML -; [:div {:class "side"} -; [:article {:class "article"} -; (header "") -; [:h2 {} "Адрес"] -; [:p {} "test!"]]])) - -; (print (HTML -; [:img {:class "side"}])) - -; (local hello {:world "test"}) - -; (print -; (fennel.view -; [(HTML [:div {:class "first"} "11111"]) -; (HTML [:div {:class "second"} "22222"])])) - -; (print (HTML -; [:div {:class "side"} -; [:article {:class "article"} -; (header "") -; [(if true :h2 :h3) {(if true "hello" "world") "test"} "Адрес"] -; [:else {:test hello.world} "Адрес"] -; [:NO-ESCAPE "<script>works!</script>"] -; [:p {} "<script>doesnt work!</script>"]]])) - -; (macrodebug -; (HTML -; [:div {:class "side"} -; (header "") -; [:article {:class "article"} -; [:h2 {} "Адрес"] -; [:p {} [:NO-ESCAPE texts.address]]] -; [:article {:class "article"} -; [:h2 {} "Форматы участия"] -; [:ol {} -; [:li {} -; [:NO-ESCAPE texts.individual-ceremony] -; [:div {:class "button-wrapper"} -; [:a {:href "https://t.me/whitetoadvlad" :class "button"} "Записаться"]]] -; [:li {} -; [:NO-ESCAPE texts.weekly-meetings] -; [:div {:class "button-wrapper"} -; [:a {:href "https://t.me/whitetoadtea" :class "button"} "Подписаться"]]]]]])) |
