summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorunwox <me@unwox.com>2025-10-03 11:56:37 +0600
committerunwox <me@unwox.com>2025-10-13 23:11:01 +0600
commit3f5ade2e7a139bb4405437e8fc5546aafc7b05ef (patch)
tree77c437958d74b591f11ec207d16749cf207a51e3
parentf5a70e6a446e00969adb866ef2e2d10bf33bc4a8 (diff)
WIP shop
-rw-r--r--.gitignore1
-rw-r--r--.nvim.lua2
-rw-r--r--bin/serve.fnl39
-rw-r--r--dicts.fnl8
-rw-r--r--forms.fnl8
-rw-r--r--lib.fnl123
-rw-r--r--macros.fnl11
-rw-r--r--pages/auth.fnl6
-rw-r--r--pages/index.fnl21
-rw-r--r--pages/shop/_product/edit.fnl6
-rw-r--r--pages/shop/_product/index.fnl42
-rw-r--r--pages/shop/add.fnl7
-rw-r--r--pages/shop/cart/add.fnl53
-rw-r--r--pages/shop/cart/remove.fnl9
-rw-r--r--pages/shop/index.fnl120
-rw-r--r--pages/shop/order.fnl59
-rw-r--r--pages/shop/order/_id.fnl35
-rw-r--r--pages/shop/order/index.fnl72
-rw-r--r--pages/shop/order/list.fnl72
-rw-r--r--pages/shop/order/state.fnl18
-rw-r--r--pages/shop/success.fnl15
-rwxr-xr-xrun.sh11
-rw-r--r--secrets.fnl.example4
-rw-r--r--shop.fnl121
-rw-r--r--static/style.css97
-rw-r--r--templates.fnl127
-rw-r--r--test.fnl112
27 files changed, 746 insertions, 453 deletions
diff --git a/.gitignore b/.gitignore
index 4130cd4..45f033b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
/var/db.*
/static/files/*
+secrets.fnl
diff --git a/.nvim.lua b/.nvim.lua
index a0e5777..60d8b28 100644
--- a/.nvim.lua
+++ b/.nvim.lua
@@ -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))
diff --git a/dicts.fnl b/dicts.fnl
index 2f09de2..a705d07 100644
--- a/dicts.fnl
+++ b/dicts.fnl
@@ -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}
diff --git a/forms.fnl b/forms.fnl
index 9b035e1..c75daec 100644
--- a/forms.fnl
+++ b/forms.fnl
@@ -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}
diff --git a/lib.fnl b/lib.fnl
index cb437a2..f5b860c 100644
--- a/lib.fnl
+++ b/lib.fnl
@@ -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}
diff --git a/macros.fnl b/macros.fnl
index 6053f6a..de9a71b 100644
--- a/macros.fnl
+++ b/macros.fnl
@@ -1,10 +1,11 @@
-(local entity-replacements {"&" "&amp;" ; must be first!
- "<" "&lt;"
- ">" "&gt;"
- "\"" "&quot;"})
+(local entity-replacements
+ {"&" "&amp;" ; must be first!
+ "<" "&lt;"
+ ">" "&gt;"
+ "\"" "&quot;"})
(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 "&nbsp;год, ")]])
- "")
- (if (not (lib.empty? product.volume))
- (HTML [:span {} [:NO-ESCAPE (.. product.volume "&nbsp;мл., ")]])
- "")
- (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)
- "₽ за&nbsp;50&nbsp;грамм "]]
- [:NO-ESCAPE "(" product.price-per
- "₽ за&nbsp;1&nbsp;грамм)"]]))]
+ (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 "&nbsp;мл., ")]])
- "")
- (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}
diff --git a/run.sh b/run.sh
index feff3a8..ebddad7 100755
--- a/run.sh
+++ b/run.sh
@@ -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>«Белая&nbsp;жаба»"]]]
+ [:h1 {} [:NO-ESCAPE "Чайный&nbsp;клуб<br>«Белая&nbsp;жаба»"]]]
[: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 "&nbsp;год, ")]])
+ "")
+ (if (not (lib.empty? product.volume))
+ (HTML [:span {} [:NO-ESCAPE (.. product.volume "&nbsp;мл., ")]])
+ "")
+ (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))
+ "₽ за&nbsp;50&nbsp;грамм "]]
+ [:NO-ESCAPE "(" (lib.format-price product.price-per)
+ "₽ за&nbsp;1&nbsp;грамм)"]]))]))
+
+(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 (.. "(&nbsp;" v ") ") "%1&nbsp;")
- (string.gsub (.. "(%s" v ") ") " %1&nbsp;")
- (string.gsub (.. "^(" v ") ") "%1&nbsp;")))))
- 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 {} "Чайная комната «Белая&nbsp;жаба»"]
- [: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"} "Подписаться"]]]]]]))