diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | bin/serve.fnl | 231 | ||||
| -rw-r--r-- | dicts.fnl | 22 | ||||
| -rw-r--r-- | forms.fnl | 288 | ||||
| -rw-r--r-- | lib.fnl | 222 | ||||
| -rw-r--r-- | macros.fnl | 95 | ||||
| -rw-r--r-- | pages/auth.fnl | 68 | ||||
| -rw-r--r-- | pages/index.fnl | 130 | ||||
| -rw-r--r-- | pages/shop/_product/edit.fnl | 75 | ||||
| -rw-r--r-- | pages/shop/_product/index.fnl | 83 | ||||
| -rw-r--r-- | pages/shop/add.fnl | 83 | ||||
| -rw-r--r-- | pages/shop/cart/add.fnl | 38 | ||||
| -rw-r--r-- | pages/shop/cart/remove.fnl | 23 | ||||
| -rw-r--r-- | pages/shop/index.fnl | 155 | ||||
| -rw-r--r-- | pages/shop/order.fnl | 61 | ||||
| -rw-r--r-- | pages/shop/success.fnl | 15 | ||||
| -rw-r--r-- | pages/staff/before-leaving.fnl | 36 | ||||
| -rwxr-xr-x | run.sh | 8 | ||||
| -rw-r--r-- | static/glasses.png | bin | 0 -> 14633 bytes | |||
| -rw-r--r-- | static/logo-bg.png | bin | 0 -> 74788 bytes | |||
| -rw-r--r-- | static/logo-dark-bg.png | bin | 0 -> 31159 bytes | |||
| -rw-r--r-- | static/style.css | 402 | ||||
| -rw-r--r-- | templates.fnl | 83 | ||||
| -rw-r--r-- | test.fnl | 113 | ||||
| -rw-r--r-- | var/.gitkeep | 0 | ||||
| -rw-r--r-- | vendor/html.fnl | 32 |
26 files changed, 1986 insertions, 279 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4130cd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/var/db.* +/static/files/* diff --git a/bin/serve.fnl b/bin/serve.fnl index 222c4b5..a76de08 100644 --- a/bin/serve.fnl +++ b/bin/serve.fnl @@ -1,54 +1,199 @@ (local fennel (require :vendor.fennel)) +(local lib (require :lib)) (when _G.unpack (tset table :unpack _G.unpack)) -(set _G.reload - (fn [module] - (local old (require module)) - (tset package :loaded module nil) - (local (ok? new) (pcall require module)) - (if (not ok?) - (do - (tset package :loaded module old) - (error new)) - (when (= (type new) :table) - (do - (each [k v (pairs new)] - (tset old k v)) - (each [k (pairs old)] - (when (not (. new k)) - (tset old k nil))) - (tset package :loaded module old)))))) - -(fn ends-with [str end] - (= (string.sub str (- (# end))) end)) - -(fn trim [str pattern] - (local pattern (or pattern "%s")) - (str:match (.. "^" pattern "*(.-)" pattern "*$"))) - -(fn file-exists? [path] - (local f (io.open path "r")) - (and (~= f nil) (io.close f))) +(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) + (local (ok? new) (pcall require module)) + (if (not ok?) + (do + (tset package :loaded module old) + (error new)) + (when (= (type new) :table) + (do + (each [k v (pairs new)] + (tset old k v)) + (each [k (pairs old)] + (when (not (. new k)) + (tset old k nil))) + (tset package :loaded module old))))) + +(local db + (_G.must (luna.db.open "file:var/db.sqlite?_journal=WAL&_sync=NORMAL&_txlock=immediate"))) + +(_G.must + (luna.db.exec db + "PRAGMA foreign_keys=ON; + + CREATE TABLE IF NOT EXISTS users( + name TEXT PRIMARY KEY, + password TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS auth_sessions( + id TEXT PRIMARY KEY, + creation_time TEXT NOT NULL, + user TEXT NOT NULL REFERENCES users(name), + expires_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS products( + name TEXT PRIMARY KEY, + creation_time TEXT NOT NULL, + title TEXT NOT NULL, + type TEXT NOT NULL, + packaging TEXT NOT NULL, + description TEXT, + short_description TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + published BOOLEAN NOT NULL DEFAULT false, + price_per REAL, + stock REAL, + vendor TEXT, + vendor_article TEXT, + vendor_description TEXT, + vendor_price_per REAL, + vendor_product_link TEXT, + image1 TEXT REFERENCES files(name), + image2 TEXT REFERENCES files(name), + image3 TEXT REFERENCES files(name), + image4 TEXT REFERENCES files(name), + image5 TEXT REFERENCES files(name) + ); + + CREATE TABLE IF NOT EXISTS files( + name TEXT PRIMARY KEY, + creation_time TEXT NOT NULL, + type TEXT NOT NULL, + size REAL NOT NULL, + thumbnail TEXT, + description TEXT + ); + + CREATE TABLE IF NOT EXISTS orders( + id TEXT PRIMARY KEY, + creation_time TEXT NOT NULL, + placement_time TEXT, + first_name TEXT, + contact TEXT + ); + + CREATE TABLE IF NOT EXISTS order_lines( + id INTEGER PRIMARY KEY, + creation_time TEXT NOT NULL, + order_id INTEGER NOT NULL REFERENCES orders(id), + product_name TEXT NOT NULL REFERENCES products(name), + quantity REAL NOT NULL + );" [])) + +(fn scan-routes [dir] + (var result {}) + (each [_ f (ipairs (_G.must (luna.fs.files dir)))] + (if f.dir + (tset result f.name (scan-routes (.. dir "/" f.name))) + (let [trimmed-name (lib.trim-right f.name ".fnl")] + (tset result trimmed-name (require (.. dir "." trimmed-name)))))) + result) + +(fn match-route [routes path] + (local variables {}) + + (fn _match [routes parts] + (var pointer routes) + (while (and (< 0 (# parts)) (= (type pointer) "table")) + (local part (table.remove parts 1)) + (set pointer + (if (. pointer part) + (. pointer part) + ;; test for routes starting with _ + (do + (var res nil) + (each [key value (pairs pointer) &until res] + (if (lib.starts-with? key "_") + (do + (set res (_match value parts)) + (when res (tset variables key part))) + nil)) + res)))) + (when (and (= (type pointer) "table") pointer.index) + (set pointer (. pointer :index))) + (values pointer variables)) + + (if (or (= path "") (= path "/")) + (values (. routes "index") variables) + (_match routes (lib.split (lib.trim path "/") "/")))) + +(fn test-match-route [] + (let [routes {:index "index" + :shop {:index "shop/index" + :cart "shop/cart" + :_product {:index "shop/_product/index" + :edit "shop/_product/edit"}} + :faq "faq" + :_page {:edit "_page/edit"}}] + + (fn test [path exp-res exp-vars] + (local (res vars) (match-route routes path)) + (assert (= exp-res res) + (.. "router test failed for " path + ": expected " exp-res ", got " res)) + (assert (lib.equal-tables? exp-vars vars) + (.. "router test failed for " path + ": expected " (fennel.view exp-vars) + ", got " (fennel.view vars)))) + + (test "" "index" {}) + (test "/" "index" {}) + (test "shop" "shop/index" {}) + (test "shop/" "shop/index" {}) + (test "shop/index" "shop/index" {}) + (test "faq" "faq" {}) + (test "faq/" "faq" {}) + (test "shop/cart" "shop/cart" {}) + (test "shop/cart/" "shop/cart" {}) + (test "foobar/edit" "_page/edit" {:_page "foobar"}) + (test "foobar/edit/" "_page/edit" {:_page "foobar"}) + (test "shop/xyz" "shop/_product/index" {:_product "xyz"}) + (test "shop/xyz/index" "shop/_product/index" {:_product "xyz"}) + (test "shop/xyz/edit" "shop/_product/edit" {:_product "xyz"}))) + +(test-match-route) + +(local routes (scan-routes "pages")) (fn router [request] - (let - [path (trim - (if (ends-with request.path "/") - (.. request.path "index") - request.path) - "/") - module-path (.. "pages." (string.gsub path "%." "/")) - module-exists? (file-exists? (.. "pages/" path ".fnl"))] - ;; FIXME: slow - (if module-exists? - (let [(code headers html) ((. (require module-path) :render) request)] - (values code headers (.. "<!DOCTYPE html>\n" html))) - (values 404 {:content-type "text/html"} "not found")))) - -(luna.router.route "GET /" router) + (if (and (lib.ends-with? request.path "/") + (~= request.path "/")) + (values 302 {:Location (lib.trim-right request.path "/")} "") + (let [(handler params) (match-route routes request.path)] + (tset request :params params) + (if (and (= (type handler) "table") handler.render) + (let [(code headers content) + (handler.render request db (lib.authenticate-request db request))] + (values code headers (.. "<!DOCTYPE html>\n" content))) + (values 404 {:content-type "text/html"} "not found"))))) + +(luna.router.route "/" router) (luna.router.static "GET /static/" "static/") +(when (= 0 (# (_G.must (luna.db.query db "SELECT name FROM users LIMIT 1" [])))) + (let [password (_G.must (luna.crypto.random-string 20)) + hash (_G.must (luna.crypto.password-hash password))] + (_G.must (luna.db.exec db "INSERT INTO users (name, password) VALUES (?, ?)" + ["admin" hash])) + (print (.. "admin user created: admin / " password)))) + (when luna.debug (luna.on-eval (fn [code] (fennel.eval code {:env _G})))) diff --git a/dicts.fnl b/dicts.fnl new file mode 100644 index 0000000..d7c72b6 --- /dev/null +++ b/dicts.fnl @@ -0,0 +1,22 @@ +(local tea-type + [{:value "white" :label "Белый"} + {:value "dark-oolong" :label "Темный улун"} + {:value "light-oolong" :label "Светлый улун"} + {:value "red" :label "Красный"} + {:value "green" :label "Зеленый"} + {:value "shu-puerh" :label "Шу пуэр"} + {:value "sheng-puerh" :label "Шэн пуэр"}]) + +(local tea-packaging + [{:value "loose" :label "Россыпь"} + {:value "piece" :label "Штучный товар"} + {:value "pieces" :label "Разлом"}]) + +(fn label [dict value] + (var result nil) + (each [_ item (ipairs dict) &until result] + (when (= item.value value) + (set result item.label))) + result) + +{: tea-type : tea-packaging : label} diff --git a/forms.fnl b/forms.fnl new file mode 100644 index 0000000..0d75afb --- /dev/null +++ b/forms.fnl @@ -0,0 +1,288 @@ +(import-macros {:compile-html <>} :macros) +(local lib (require :lib)) + +(local required-marker + (<> [:span {:class "form-required-marker"} " (обяз.)"])) + +(fn textarea-input [name label required? minlength maxlength help] + (local minlength (or minlength 0)) + (local maxlength (or maxlength 1000)) + + {: name : label : required? : help + :validate + (fn [value] + (if (<= minlength (# value) maxlength) + nil + "Некорректная длина текста.")) + :html + (fn [value error] + (<> + [:div {:class "form-row"} + [:label {:class "form-label" :for name} + label (if required? required-marker "")] + [:textarea (fn [] {:name name :id name :class "form-input" + :minlength (tostring minlength) + :maxlength (tostring maxlength) + :required required?}) + (or value "")] + (if error (<> [:div {:class "form-error"} error]) "") + (if help (<> [:div {:class "form-help"} help]) "")]))}) + +(fn text-input [name label required? minlength maxlength help] + (local minlength (or minlength 0)) + (local maxlength (or maxlength 200)) + + {: name : label : required? : help + :validate + (fn [value] + (if (<= minlength (# value) maxlength) + nil + "Некорректная длина текста.")) + :html + (fn [value error] + (<> + [:div {:class "form-row"} + [:label {:class "form-label" :for name} + label (if required? required-marker "")] + [:input (fn [] {:type "text" :name name :id name :class "form-input" + :minlength (tostring minlength) + :maxlength (tostring maxlength) + :required required? + :value value})] + (if error (<> [:div {:class "form-error"} error]) "") + (if help (<> [:div {:class "form-help"} help]) "")]))}) + +(fn password-input [name label required? minlength maxlength help] + (local minlength (or minlength 0)) + (local maxlength (or maxlength 200)) + + {: name : label : required? : help + :validate + (fn [value] + (if (<= minlength (# value) maxlength) + nil + "Некорректная длина текста.")) + :html + (fn [value error] + (<> + [:div {:class "form-row"} + [:label {:class "form-label" :for name} + label (if required? required-marker "")] + [:input (fn [] {:type "password" :name name :id name :class "form-input" + :minlength (tostring minlength) + :maxlength (tostring maxlength) + :required required? + :value value})] + (if error (<> [:div {:class "form-error"} error]) "") + (if help (<> [:div {:class "form-help"} help]) "")]))}) + +(fn url-input [name label required? help] + {: name : label : required? : help + :validate + (fn [value] + (if (<= 0 (# value) 1000) + nil + "Некорректная длина ссылки.")) + :html + (fn [value error] + (<> + [:div {:class "form-row"} + [:label {:class "form-label" :for name} + label (if required? required-marker "")] + [:input (fn [] {:type "url" :name name :id name :class "form-input" + :required required? :value value})] + (if error (<> [:div {:class "form-error"} error]) "") + (if help (<> [:div {:class "form-help"} help]) "")]))}) + +(fn number-input [name label required? min max help] + (local min (or min 0)) + (local max (or max 100)) + + {: name : label : required? : help + :validate + (fn [value] + (if (<= min (tonumber value) max) + nil + "Некорректное число.")) + :html + (fn [value error] + (<> + [:div {:class "form-row"} + [:label {:class "form-label" :for name} label (if required? required-marker "")] + [:input (fn [] {:type "number" :name name :id name :class "form-input" + :required required? + :min (tostring min) :max (tostring max) + :value (tostring value)})] + (if error (<> [:div {:class "form-error"} error]) "") + (if help (<> [:div {:class "form-help"} help]) "")]))}) + +(fn checkbox-input [name label required? help] + {: name : label : required? : help + :value-from-html + (fn [value] (= value "on")) + :html + (fn [value error] + (<> + [:div {:class "form-row"} + [:input (fn [] {:type "checkbox" :name name :id name :class "form-input" + :required required? + :checked (or (= value "on") (= value true))})] + [:label {:class "form-label" :for name} label (if required? required-marker "")] + (if error (<> [:div {:class "form-error"} error]) "") + (if help (<> [:div {:class "form-help"} help]) "")]))}) + +(fn file-input [name label required? accept thumbnail-width help] + {:type "file" : name : label : required? : help + :value-from-html + (fn [value {: data : db}] + (if (= (type value) "table") + (lib.handle-upload db value nil thumbnail-width) + (not (lib.empty? value)) + value + (. data (.. name "_previous")))) + :html + (fn [value error] + (local empty-value? (lib.empty? value)) + (local required? (and empty-value? required?)) + + (<> + [:div {:class "form-row"} + [:div {:class "d-flex gap-1"} + (if (and value (lib.ends-with? value ".jpg")) + (<> [:img {:class "form-file-img" :src (.. "/static/files/" value)}]) + "") + [:div {} + [:label {:class "form-label" :for name} label (if required? required-marker "")] + [:input (fn [] {:type "file" :name name :id name :class "form-input" + :required required? :accept accept})]]] + (if (not empty-value?) + (<> [:input {:type "hidden" :name (.. name "_previous") :value value}]) + "") + (if error (<> [:div {:class "form-error"} error]) "") + (if help (<> [:div {:class "form-help"} help]) "")]))}) + +(fn select-input [name label required? options help] + {: name : label : required? : options : help + :validate + (fn [value] + (var exists? false) + (each [_ option (ipairs options)] + (when (= option.value value) + (set exists? true))) + (if exists? nil "Некорректное значение.")) + :html + (fn [value error] + (<> + [:div {:class "form-row"} + [:label {:class "form-label" :for name} label (if required? required-marker "")] + [:select (fn [] {:name name :id name + :required required?}) + [:option [:selected "selected"] ""] + (table.concat + (icollect [_ option (ipairs options)] + (<> + [:option + (fn [] {:value option.value :selected (= value option.value)}) + option.label])))] + (if error [:div {:class "form-error"} error] "") + (if help [:div {:class "form-help"} help] "")]))}) + +(fn render-form [form data errors] + (<> + [:form {:class "form" :enctype "multipart/form-data" :method "POST"} + (table.concat + (lib.append + (icollect [_ group (ipairs form)] + (<> + [:div {:class "form-group"} + [:h3 {:class "form-subheader"} group.title] + (table.concat + (icollect [_ field (ipairs group.fields)] + (field.html (. data field.name) (. errors field.name))))])) + (<> [:button {:type "submit"} "Сохранить"])))])) + +(fn convert-values-from-html [form data db] + (each [_ group (ipairs form)] + (each [_ field (ipairs group.fields)] + (local value (. data field.name)) + (when field.value-from-html + (tset data field.name (field.value-from-html value {: data : db}))))) + data) + +(fn validate-form [form data] + (var errors []) + (each [_ group (ipairs form)] + (each [_ field (ipairs group.fields)] + (local value (. data field.name)) + (local empty-value? (lib.empty? value)) + (when (and field.required? empty-value?) + (tset errors field.name "Поле должно быть заполнено.")) + (when (and value (not empty-value?) field.validate) + (local err (field.validate value)) + (when err (tset errors field.name err))))) + errors) + +(fn form-insert-sql-statement [table-name form data extra-data] + (var columns []) + (var args []) + + (each [_ group (ipairs form)] + (each [_ field (ipairs group.fields)] + (local value (. data field.name)) + (when (not (lib.empty? value)) + (table.insert columns field.name) + (table.insert args value)))) + + (when extra-data + (each [key value (pairs extra-data)] + (table.insert columns key) + (table.insert args value))) + + (if (< 0 (# columns)) + [(.. "INSERT INTO " table-name " (" (table.concat columns ", ") ") VALUES " + "(" (table.concat (icollect [_ _ (ipairs columns)] "?") ", ") ")") + args] + nil)) + +(fn form-update-sql-statement [table-name form data extra-data where] + (var columns []) + (var args []) + + (each [_ group (ipairs form)] + (each [_ field (ipairs group.fields)] + (local value (. data field.name)) + (when (not (lib.empty? value)) + (table.insert columns field.name) + (table.insert args value)))) + + (when extra-data + (each [key value (pairs extra-data)] + (table.insert columns key) + (table.insert args value))) + + (var where-columns []) + + (when where + (each [key value (pairs where)] + (table.insert where-columns key) + (table.insert args value))) + + (if (< 0 (# columns)) + [(.. "UPDATE " table-name " SET " (table.concat columns " = ?, ") " = ? " + "WHERE " (table.concat where-columns " = ?, ") " = ?") + args] + nil)) + +{: textarea-input + : text-input + : password-input + : number-input + : checkbox-input + : url-input + : file-input + : select-input + : render-form + : convert-values-from-html + : validate-form + : form-insert-sql-statement + : form-update-sql-statement} @@ -0,0 +1,222 @@ +(fn now [] + (os.date "%Y-%m-%d %H:%M:%S")) + +(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 test-improve-typography [] + (assert (= (improve-typography "Я лежу на пляжу!") + "Я лежу на пляжу!")) + (assert (= (improve-typography "500 рублей мы сняли со счета!") + "500 рублей мы сняли со счета!")) + (assert (= (improve-typography "500 рублей мы + сняли со счета!") + "500 рублей мы сняли со счета!"))) + +(fn starts-with? [str start] + (= (string.sub str 1 (# start)) start)) + +(fn ends-with? [str end] + (= (string.sub str (- (# end))) end)) + +(fn trim-left [str pattern] + (local pattern (or pattern "%s")) + (str:match (.. "^" pattern "*(.-)$"))) + +(fn trim-right [str pattern] + (local pattern (or pattern "%s")) + (str:match (.. "^(.-)" pattern "*$"))) + +(fn trim [str pattern] + (local pattern (or pattern "%s")) + (str:match (.. "^" pattern "*(.-)" pattern "*$"))) + +(fn file-exists? [path] + (local f (io.open path "r")) + (and (~= f nil) (io.close f))) + +(fn parse-values [val] + (assert (= (type val) "string") "val must be string") + (accumulate [result {} k v (string.gmatch val "([%w-]+)=([%w-%%]+)")] + (do + (tset result k v) + result))) + +(fn handle-upload [db file description thumbnail-width] + (local description (or description "")) + ;; FIXME: check mimetype if we will allow uploads from users! + (let [type (_G.must (luna.fs.mimetype file.path)) + sanitized-name (-> file.name + (string.gsub "^[%./\\]+" "") + (string.gsub "[^a-zA-Z0-9-_.]" "_") + (string.gsub "%.+" ".")) + final-name (.. (_G.must (luna.crypto.random-string 8)) "-" sanitized-name) + thumbnail-name (.. "static/files/" final-name "-thumbnail.jpg") + thumbnail (if thumbnail-width + (_G.must + (luna.image.create-thumbnail + file.path thumbnail-name thumbnail-width)) + "")] + (_G.must + (luna.db.exec db + "INSERT INTO files(name, creation_time, type, size, description, thumbnail) + VALUES (?, ?, ?, ?, ?, ?)" + [final-name (now) type file.size description thumbnail-name])) + ;; FIXME: also handle file collisions + (os.execute (.. "mv " file.path " ./static/files/" final-name)) + + 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)) + (if ok? + (do + (luna.db.commit tx) + result) + (do + (luna.db.rollback tx) + (error result))))) + +(fn empty-table? [t] + (= (next t) nil)) + +(fn empty? [v] + (or (= v nil) (= v ""))) + +(fn split [str delimiter] + (assert delimiter "delimiter must be specified") + (if (empty? str) + [] + (do + (local result {}) + (local len (# str)) + (var cursor 1) + (var (start end) (str:find delimiter)) + (while start + (when (< cursor start) + (table.insert result (str:sub cursor (- start 1)))) + (set cursor (+ end 1)) + (set (start end) (str:find delimiter cursor))) + (when (<= cursor len) + (table.insert result (str:sub cursor len))) + result))) + +(fn concat [...] + (local res []) + (var cur 1) + (each [_ t (pairs [...])] + (each [_ v (pairs t)] + (tset res cur v) + (set cur (+ 1 cur)))) + res) + +(fn merge [...] + (local res []) + (each [_ t (pairs [...])] + (each [k v (pairs t)] + (tset res k v))) + res) + +(fn append [t e] + (table.insert t e) + t) + +(fn equal-tables? [t1 t2] + (assert (= (type t1) "table") "t1 must be a table") + (assert (= (type t2) "table") "t2 must be a table") + + (if (~= (# t1) (# t2)) + false + (accumulate [res true + 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))) + +{: improve-typography + : starts-with? + : ends-with? + : trim + : trim-left + : trim-right + : file-exists? + : parse-values + : order-id + : handle-upload + : with-tx + : append + : equal-tables? + : empty-table? + : split + : concat + : merge + : empty? + : now + : basket + : string->number + : authenticate-request} diff --git a/macros.fnl b/macros.fnl new file mode 100644 index 0000000..fdfe17c --- /dev/null +++ b/macros.fnl @@ -0,0 +1,95 @@ +(local entity-replacements {"&" "&" ; must be first! + "<" "<" + ">" ">" + "\"" """}) + +(local entity-search + (.. "[" (table.concat (icollect [k (pairs entity-replacements)] k)) "]")) + +(fn escape-html [s] + (assert (= (type s) :string)) + (s:gsub entity-search entity-replacements)) + +(fn compile-attributes [attributes] + (var result []) + (if + (table? attributes) + (each [name value (pairs attributes)] + (when value + (do + (table.insert result " ") + (table.insert result name) + (when (~= value true) + (do + (table.insert result "=\"") + (table.insert result value) + (table.insert result "\"")))))) + (list? attributes) + (table.insert + result + `(accumulate [res# "" k# v# (pairs (,attributes))] + (.. " " + (if + (not v#) "" + (= v# true) (.. k#) + (.. k# "=\"" v# "\"")) + res#)))) + result) + +(fn concat [...] + (local res []) + (var cur 1) + (each [_ t (pairs [...])] + (each [_ v (pairs t)] + (tset res cur v) + (set cur (+ 1 cur)))) + res) + +(fn compress-compiled-html [html] + (var result []) + (var accumulator "") + (each [_ value (pairs html)] + (if (= (type value) "string") + (set accumulator (.. accumulator value)) + (do + (when (~= "" accumulator) + (do + (table.insert result accumulator) + (set accumulator ""))) + (table.insert result value)))) + (when (~= "" accumulator) + (table.insert result accumulator)) + result) + +(fn compile-html [html] + (fn inner-compile [html] + (var result []) + (fn add [item] (table.insert result item)) + (let [[tag attributes & body] html] + (add "<") (add tag) + (set result (concat result (compile-attributes attributes))) + (add ">") + (each [_ item (pairs body)] + (if + (and (sequence? item) (= (. item 1) "NO-ESCAPE")) + (add (. item 2)) + (sequence? item) + (set result (concat result (inner-compile item))) + (list? item) + ;; FIXME: add escaping to results + (add + (if (or (= (. item 1) `table.unpack) + (= (. item 1) `unpack)) + `(.. ,item) + item)) + (= (type item) "string") + (add (escape-html item)) + (add item))) + (when (< 0 (# body)) + (add "</") (add tag) (add ">")) + (add "\n")) + result) + + `(.. ,(table.unpack (compress-compiled-html (inner-compile html))))) + +{: compile-html} diff --git a/pages/auth.fnl b/pages/auth.fnl new file mode 100644 index 0000000..fc35be0 --- /dev/null +++ b/pages/auth.fnl @@ -0,0 +1,68 @@ +(import-macros {:compile-html <>} :macros) +(local forms (require :forms)) +(local lib (require :lib)) +(local templates (require :templates)) + +(local auth-form [ + {:title "" + :fields [ + (forms.text-input "name" "Пользователь" true) + (forms.password-input "password" "Пароль" true)]}]) + +(fn content [data errors] + (set data.password nil) + [(<> + [:div {:class "side"} + (templates.header "/auth")]) + (<> + [:section {:class "content"} + [:div {:class "mb-1"} [:a {:href "/"} "⟵ Обратно на главную"]] + [:h2 {} "Войти"] + (forms.render-form auth-form data errors)])]) + +(fn create-session [db user] + (local id (_G.must (luna.crypto.random-string 64))) + (local next-week (os.date "%Y-%m-%d %H:%M:%S" + (+ (os.time) (* 60 60 24 7)))) + (_G.must + (luna.db.exec + db "INSERT INTO auth_sessions (id, user, creation_time, expires_at) + VALUES (?, ?, ?, ?)" + [id user (lib.now) next-week])) + + id) + +(fn check-user [db name entered-pass] + (local users + (_G.must + (luna.db.query db + "SELECT users.password + FROM USERS + WHERE LOWER(users.name) = ? + LIMIT 1" [name]))) + + (if (< 0 (# users)) + (let [password (. users 1 1)] + (_G.must (luna.crypto.check-password entered-pass password))) + false)) + +(fn render [request db authenticated?] + (if authenticated? + (values 302 {:Location "/"} "") + (if request.form + (let [name request.form.name + entered-password request.form.password + correct-creds? (check-user db name entered-password) + errors (if (not correct-creds?) + {:password "Пользователя с таким именем и паролем не существует."} + nil)] + (if correct-creds? + (do + (local session-id (create-session db name)) + (values 302 {:Location "/shop" + :Set-Cookie (.. "auth= " session-id "; HttpOnly; SameSite=strict" + (if luna.debug? "" "; Secure"))} "")) + (values 200 {} (templates.base (content request.form errors))))) + (values 200 {} (templates.base (content {} {})))))) + +{: render} diff --git a/pages/index.fnl b/pages/index.fnl index 4d8af80..f0ac111 100644 --- a/pages/index.fnl +++ b/pages/index.fnl @@ -1,101 +1,71 @@ +(import-macros {:compile-html <>} :macros) +(local lib (require :lib)) (local templates (require :templates)) -(local html (require :vendor.html)) - -(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 test-improve-typography [] - (assert (= (improve-typography "Я лежу на пляжу!") - "Я лежу на пляжу!")) - (assert (= (improve-typography "500 рублей мы сняли со счета!") - "500 рублей мы сняли со счета!")) - (assert (= (improve-typography "500 рублей мы - сняли со счета!") - "500 рублей мы сняли со счета!"))) (local texts { :address - (improve-typography + (lib.improve-typography "г. Омск, ул. Пушкина, д. 133/9, этаж 2. Вход с крыльца Магнита, дверь - слева, домофон 4") + слева, домофон 4. Дверь в офисе узнаете по нашему логотипу.") :individual-ceremony - (improve-typography + (lib.improve-typography "Индивидуальная чайная церемония: мастер готовит для вас чай на ваш выбор. О времени встречи договариваемся. Стоимость 1000 рублей с человека, до 5 человек.") :weekly-meetings - (improve-typography + (lib.improve-typography "Еженедельное мероприятие: каждую субботу в 15:00 мы собираемся и пьем чай из нашей коллекции. Для посещения необходима запись в комментариях - под соответствующим постом в нашей группе в телеграме. Стоимость 500 - рублей с человека.") -}) + под соответствующим постом в нашей группе. Стоимость 500 рублей + с человека.")}) (fn pick-gallery-photo [list] (let [chosen (. list (math.random (# list)))] - [:div {} - [:a {:href (.. "static/" chosen.name ".webp") :target "_blank"} - [:img {:src (.. "static/" chosen.name "-p.webp") :alt chosen.alt}]]])) + (<> + [:div {} + [:a {:href (.. "static/" chosen.name ".webp") :target "_blank"} + [:img {:src (.. "static/" chosen.name "-p.webp") :alt chosen.alt}]]]))) -(fn content [] - [[:div {:class "content"} - [:article {:class "article"} - [:img {:class "logo" :src "/static/logo.svg" - :alt "Белая жаба в мультяшном стиле с чайником на голове"}] - [:h1 {} [:NO-ESCAPE "Чайная комната «Белая жаба»"]] - [:nav {} - [:a {:href "https://t.me/whitetoadtea"} "телеграм"] - [:span {} "~"] - [:a {:href "https://vk.com/whitetoadtea"} "вконтакте"]]] - [: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"} "Подписаться"]]]]]] - [:div {:class "gallery"} - (pick-gallery-photo [{:name "people" :alt "Люди в чайной"} - {:name "gaiwan2" :alt "Близко сфотографированный чайный столик с пиалами и гайванью"} - {:name "teapot2" :alt "Пиала и чайник на фоне гирлянды"}]) - (pick-gallery-photo [{:name "table" :alt "Сфотографированные сверху пиалы на японском столике"}]) - [:div {} - [:video {:autoplay true :loop true :muted true} - [:source {:src "static/boiling-p.webm" :type "video/webm"}]]] - (pick-gallery-photo [{:name "gaiwan" :alt "Гайвань и ширма с гирляндами на фоне"} - {:name "wall" :alt "Белая стена с веером и тенью окна"} - {:name "teapot" :alt "Чайник в темноте на светлом фоне гирлянд"} - {:name "teapot3" :alt "Чайник в руке на фоне растений"}]) - (pick-gallery-photo [{:name "rack" :alt "Стеллаж с чайной посудой"} - {:name "flowers" :alt "Ваза с цветами рядом с чайным столиком"}]) - [:div {} - [:video {:autoplay true :loop true :muted true} - [:source {:src "static/gaiwan-p.webm" :type "video/webm"}]]]]]) +(fn content [authenticated?] + [(<> + [:div {:class "side mb-2"} + (templates.header "" authenticated?) + [:section {:class "mb-2"} + [:h2 {} "Адрес"] + [:p {} [:NO-ESCAPE texts.address]]] + [:section {} + [:h2 {} "Форматы участия"] + [:div {:class "mb-2"} + [: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"} "Подписаться ⟶"]]]]]) + (<> + [:div {:class "content"} + [:div {:class "gallery"} + (pick-gallery-photo [{:name "people" :alt "Люди в чайной"} + {:name "gaiwan2" :alt "Близко сфотографированный чайный столик с пиалами и гайванью"} + {:name "teapot2" :alt "Пиала и чайник на фоне гирлянды"}]) + (pick-gallery-photo [{:name "table" :alt "Сфотографированные сверху пиалы на японском столике"}]) + [:div {} + [:video {:autoplay true :loop true :muted true} + [:source {:src "static/boiling-p.webm" :type "video/webm"}]]] + (pick-gallery-photo [{:name "gaiwan" :alt "Гайвань и ширма с гирляндами на фоне"} + {:name "wall" :alt "Белая стена с веером и тенью окна"} + {:name "teapot" :alt "Чайник в темноте на светлом фоне гирлянд"} + {:name "teapot3" :alt "Чайник в руке на фоне растений"}]) + (pick-gallery-photo [{:name "rack" :alt "Стеллаж с чайной посудой"} + {:name "flowers" :alt "Ваза с цветами рядом с чайным столиком"}]) + [:div {} + [:video {:autoplay true :loop true :muted true} + [:source {:src "static/gaiwan-p.webm" :type "video/webm"}]]]]])]) -(fn render [request] - (values 200 {} (html.render (templates.base-template (content)) true))) +(fn render [request _ authenticated?] + (values 200 {} (templates.base (content authenticated?)))) {: render} diff --git a/pages/shop/_product/edit.fnl b/pages/shop/_product/edit.fnl new file mode 100644 index 0000000..9465d94 --- /dev/null +++ b/pages/shop/_product/edit.fnl @@ -0,0 +1,75 @@ +(import-macros {:compile-html <>} :macros) +(local templates (require :templates)) +(local {: product-form} (require :pages.shop.add)) +(local forms (require :forms)) +(local lib (require :lib)) + +(fn find-product [db name] + (. + (_G.must + (luna.db.query-assoc + db + "SELECT products.published, + products.name, + products.title, + products.position, + products.short_description, + products.stock, + products.type, + products.packaging, + products.description, + products.price_per, + products.stock, + products.vendor, + products.vendor_article, + products.vendor_description, + products.vendor_price_per, + products.vendor_product_link, + products.image1, + products.image2, + products.image3, + products.image4, + products.image5 + FROM products + WHERE products.name = ?" + [name])) + 1)) + +(fn update-product [tx form data where] + (let [sql-and-args (forms.form-update-sql-statement "products" form data {} where)] + (if sql-and-args + (_G.must (luna.db.exec-tx tx (table.unpack sql-and-args))) + (error "empty data for insert SQL-statement")))) + +(fn content [form data errors authenticated?] + [(<> + [:div {:class "side"} + (templates.header "/shop" authenticated?)]) + (<> + [:div {:class "content"} + [:div {:class "mb-1"} + [:a {:href (.. "/shop/" data.name)} "⟵ Обратно к товару"]] + [:h2 {} "Редактировать товар"] + (forms.render-form form data errors)])]) + +(fn render [request db authenticated?] + (if (not authenticated?) + (values 302 {:Location "/shop"} "") + (if request.form + (let [data (forms.convert-values-from-html product-form request.form db) + errors (forms.validate-form product-form data) + has-errors? (not (lib.empty-table? errors))] + (if has-errors? + (values 400 {} (templates.base (content product-form data errors + authenticated?))) + (do + (lib.with-tx db + (fn [tx] (update-product tx product-form data + {:name request.params._product}))) + (values 302 {:Location (.. "/shop/" data.name)} "")))) + (values 200 {} + (templates.base + (content product-form (find-product db request.params._product) {} + authenticated?)))))) + +{: render } diff --git a/pages/shop/_product/index.fnl b/pages/shop/_product/index.fnl new file mode 100644 index 0000000..722c952 --- /dev/null +++ b/pages/shop/_product/index.fnl @@ -0,0 +1,83 @@ +(import-macros {:compile-html <>} :macros) +(local templates (require :templates)) +(local dicts (require :dicts)) +(local lib (require :lib)) + +(fn text->html [text] + (assert (= (type text) "string")) + (var result "") + (var from 1) + (var to (text:find "\n%s*\n%s*" from)) + (while to + (set result (.. result "<p>" (text:sub from (- to 1)) "</p>\n")) + (set from (+ to 2)) + (set to (text:find "\n%s*\n%s*" from))) + (.. result "<p>" (text:sub from) "</p>")) + +(fn find-product [db name] + (. + (_G.must + (luna.db.query-assoc + db + "SELECT products.name, + products.title, + products.description, + products.price_per AS \"price-per\", + products.type, + products.stock, + products.published, + products.image1, + products.image2, + products.image3, + products.image4, + products.image5 + FROM products + WHERE products.name = ?" + [name])) + 1)) + +(fn content [product authenticated?] + (local images []) + (for [i 1 5] + (table.insert images (. product (.. "image" i)))) + + [(<> + [:div {:class "side"} + (templates.header "/shop" authenticated?)]) + (<> + [:div {:class "content"} + [:div {:class "mb-1"} [:a {:href "/shop"} "⟵ Обратно к списку"]] + + (let [link (.. "/static/files/" product.image1)] + (<> [:a {:href link :target "_blank"} + [:img {:class "product-page-img-mobile mb-1-5" + :src (.. link "-thumbnail.jpg")}]])) + [:div {:class "product-page-layout"} + [:div {} + [:h2 {:class "product-page-title mb-1"} product.title] + [:section {:class "mb-2"} + (if authenticated? + (<> [:div {:class "mb-0-5"} + [:a {:href (.. "/shop/" product.name "/edit")} + "✎ Редактировать"]]) + "") + [:div {:class "mb-0-5" :style "font-style: italic;"} + (or (dicts.label dicts.tea-type product.type) product.type) ", " + [:strong {} (* 50 product.price-per) "₽ за 50 грамм "] + (.. "(" product.price-per "₽ за 1 грамм)")]] + [:div {:class "mb-2"} "~~~"] + [:NO-ESCAPE (text->html product.description)]] + [:div {:class "product-page-imgs"} + (table.concat + (icollect [idx image (ipairs images)] + (let [link (.. "/static/files/" image)] + (<> [:a {:href link :target "_blank"} + [:img {:class "product-page-img" :src (.. link "-thumbnail.jpg")}]]))))]]])]) + +(fn render [request db authenticated?] + (let [product (find-product db request.params._product)] + (if (and product (or product.published authenticated?)) + (values 200 {} (templates.base (content product authenticated?))) + (values 404 {} "not found")))) + +{: render} diff --git a/pages/shop/add.fnl b/pages/shop/add.fnl new file mode 100644 index 0000000..8fcfdf1 --- /dev/null +++ b/pages/shop/add.fnl @@ -0,0 +1,83 @@ +(import-macros {:compile-html <>} :macros) +(local templates (require :templates)) +(local dicts (require :dicts)) +(local forms (require :forms)) +(local lib (require :lib)) + +(local product-form + [{:title "" + :fields [ + (forms.checkbox-input "published" "Опубликован" false + "Отображать ли товар на страницах магазина.") + (forms.number-input "position" "Позиция в списке" true 0 1000 + "Чем выше число, тем позже в списке будет находиться товар.") + (forms.text-input "name" "Алиас" true 0 125 + (.. "Уникальное название чая на латинице, без пробелов, " + "в нижнем регистре.")) + (forms.text-input "title" "Полное название" true 0 200) + (forms.select-input "type" "Вид чая" true dicts.tea-type) + (forms.select-input "packaging" "Упаковка" true dicts.tea-packaging) + (forms.textarea-input "short_description" "Короткое описание" true 0 1000) + (forms.textarea-input "description" "Полное описание" true 0 20000) + (forms.number-input "price_per" "Цена" true 0 100000 + "За штуку или грамм.") + (forms.number-input "stock" "Сколько в наличии" true 0 100000 + "В штуках или граммах.")]} + + {:title "Фото" + :fields [ + (forms.file-input "image1" "Первое" true ".jpg" 512) + (forms.file-input "image2" "Второе" false ".jpg" 512) + (forms.file-input "image3" "Третье" false ".jpg" 512) + (forms.file-input "image4" "Четвертое" false ".jpg" 512) + (forms.file-input "image5" "Пятое" false ".jpg" 512)]} + + {:title "Данные о поставщике" + :fields [ + (forms.select-input "vendor" "Поставщик" false + [{:value "oz" :label "Орехово-Зуево"} + {:value "chaibez" :label "Чай без церемоний"}]) + (forms.text-input "vendor_article" "Артикль или ID товара у поставщика" false + 0 50) + (forms.textarea-input "vendor_description" "Описание товара от поставщика" + false 0 10000) + (forms.number-input "vendor_price_per" "Цена поставщика" false 0 100000 + "За штуку или грамм.") + (forms.url-input "vendor_product_link" "Ссылка на чай у поставщика" false)]}]) + +(fn insert-product [tx form data] + (let [sql-and-args (forms.form-insert-sql-statement "products" form data + {:creation_time (lib.now)})] + (if sql-and-args + (_G.must (luna.db.exec-tx tx (table.unpack sql-and-args))) + (error "empty data for insert SQL-statement")))) + +(fn content [form data errors authenticated?] + [(<> + [:div {:class "side"} + (templates.header "/shop" authenticated?)]) + (<> + [:div {:class "content"} + [:div {:class "mb-1"} + [:a {:href "/shop"} "⟵ Обратно к списку"]] + [:h2 {} "Добавить товар"] + (forms.render-form form data errors)])]) + +(fn render [request db authenticated?] + (if (not authenticated?) + (values 302 {:Location "/shop"} "") + (if request.form + (let [data (forms.convert-values-from-html product-form request.form db) + errors (forms.validate-form product-form request.form) + has-errors? (not (lib.empty-table? errors))] + (if has-errors? + (values 200 {} (templates.base (content product-form request.form errors + authenticated?))) + (do + (lib.with-tx db + (fn [tx] (insert-product tx product-form request.form))) + (values 302 {:Location "/shop"} "")))) + (values 200 {} (templates.base (content product-form {} {} + authenticated?)))))) + +{: render : product-form} diff --git a/pages/shop/cart/add.fnl b/pages/shop/cart/add.fnl new file mode 100644 index 0000000..36e3e41 --- /dev/null +++ b/pages/shop/cart/add.fnl @@ -0,0 +1,38 @@ +(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]))) + +(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"))}) + {})) + + (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 404 {} "not found"))) + +{: render} diff --git a/pages/shop/cart/remove.fnl b/pages/shop/cart/remove.fnl new file mode 100644 index 0000000..d5e3531 --- /dev/null +++ b/pages/shop/cart/remove.fnl @@ -0,0 +1,23 @@ +(local lib (require :lib)) + +(fn render [request db] + (if (= request.method "POST") + (let [order-id (lib.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])) + (values + 302 + {:Location (_G.must + (luna.http.decode-url + (or body-values.redirect-url "/shop")))} + "")) + (values 400 {} "bad body"))) + (values 404 {} "not found"))) + +{: render} diff --git a/pages/shop/index.fnl b/pages/shop/index.fnl new file mode 100644 index 0000000..5a96ab2 --- /dev/null +++ b/pages/shop/index.fnl @@ -0,0 +1,155 @@ +(import-macros {:compile-html <>} :macros) +(local lib (require :lib)) +(local dicts (require :dicts)) +(local templates (require :templates)) + +(fn all-products [db authenticated?] + (local where + (if (not authenticated?) + "WHERE products.published = true" + "")) + (_G.must + (luna.db.query-assoc db + (.. + "SELECT products.name, + products.title, + products.published, + products.short_description as \"short-description\", + products.price_per AS \"price-per\", + products.type, + products.image1, + products.image2, + products.image3, + products.image4, + products.image5 + FROM products " + 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 + ;; (<> + ;; [:option {:value (tostring q)} + ;; (.. q " грамм за " (* product.price-per q) "₽")]))) + ;; (table.insert quantity-options (<> [:option {:value "0"} "Товар закончился"]))) + + (local images []) + (for [i 2 5] + (table.insert images (. product (.. "image" i)))) + + (<> + [:section {:class (.. "shop-item" + (if (not product.published) + " shop-item-not-published" ""))} + [:a {:href item-url} + [:div {:class "shop-item-imgs"} + [:img {:class "shop-item-img" + :src (.. "/static/files/" (. product.image1) "-thumbnail.jpg")}] + (table.concat + (icollect [idx image (ipairs images)] + (<> + [:img {:class "shop-item-img" :src (.. "/static/files/" image "-thumbnail.jpg") + :loading "lazy" + :style (.. "z-index: " (+ idx 2) ";" + "width: calc(100% / " (# images) ");" + "left: calc(100% / " (# images) " * " (- 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.tea-type product.type) product.type) ", " + [: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"} "Добавить"]]] + [:div {} product.short-description]])) + +(fn content [db basket basket-total authenticated?] + [(<> + [:div {:class "side"} + (templates.header "/shop" authenticated?) + (if (< 0 (# basket)) + (<> + [: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"} "Оформить заказ"]]) + "")]) + (<> + [:div {:class "content"} + [:div {:class "mb-1"} [:a {:href "/"} "⟵ Обратно на главную"]] + [:h2 {:class "mb-1 product-page-title"} + "Магазин" + (if authenticated? + (<> [:a {:style "font-size: 1rem; margin-left: 0.75rem;" + :href (.. "/shop/add")} "+ Добавить"]) + "")] + [:div {:class "shop-items"} + (let [products (all-products db authenticated?)] + (if (< 0 (# products)) + (table.concat + (icollect [_ v (ipairs products)] + (item-template v))) + (<> [: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?)))))) + +{: render} diff --git a/pages/shop/order.fnl b/pages/shop/order.fnl new file mode 100644 index 0000000..6edaf8a --- /dev/null +++ b/pages/shop/order.fnl @@ -0,0 +1,61 @@ +(local lib (require :lib)) +(local templates (require :templates)) +(local html (require :vendor.html)) + +(fn content-template [db basket basket-total] + [[:div {:class "side"} + (templates.header "/shop/order")] + [: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 = ?" + [(os.date "%Y-%m-%d %H:%M:%S") 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 {} + (html.render + (templates.base (content-template db basket basket-total)) + true)) + (values 302 {:Location "/shop"} ""))))) + +{: render} diff --git a/pages/shop/success.fnl b/pages/shop/success.fnl new file mode 100644 index 0000000..2e9abb4 --- /dev/null +++ b/pages/shop/success.fnl @@ -0,0 +1,15 @@ +(local templates (require :templates)) +(local html (require :vendor.html)) + +(tset _G :package :loaded "pages.shop.success" nil) + +(fn content [] + [[:div {:class "side"} + (templates.header "/shop/order")] + [:div {:class "content"} + "Спасибо за заказ!"]]) + +(fn render [] + (values 200 {} (html.render (templates.base (content)) true))) + +{: render} diff --git a/pages/staff/before-leaving.fnl b/pages/staff/before-leaving.fnl deleted file mode 100644 index b358bdf..0000000 --- a/pages/staff/before-leaving.fnl +++ /dev/null @@ -1,36 +0,0 @@ -(local templates (require :templates)) -(local html (require :vendor.html)) - -(local content - [[:article {:class "article"} - [:img {:class "logo" :src "/static/logo.svg" - :alt "Белая жаба в мультяшном стиле с чайником на голове"}] - [:h1 {} [:NO-ESCAPE "Чайная «Белая жаба»"]] - [:nav {} - [:a {:href "https://t.me/whitetoadtea"} "телеграм"] - [:span {} "~"] - [:a {:href "https://vk.com/whitetoadtea"} "вконтакте"]] - [:p {} - (table.unpack - (icollect [_ v (ipairs ["Затушить свечи" - "Убрать чай" - "Промыть, протереть и убрать посуду" - "Слить и протереть чабань" - "Протереть стол" - "Слить чайник" - "Слить ведро" - "Закрыть окна" - "Вытащить электронику из розеток" - "Выключить сетевой фильтр" - "Выключить гирлянды" - "Закрыть дверь в офис" - "Поставить на сигнализацию" - "Закрыть дверь в офисы (если уходим последние)"])] - [:div {} - [:label {:class "form-row"} - [:input {:type "checkbox"}] v]]))]]]) - -(fn render [request] - (values 200 {} (html.render (templates.base-template content) true))) - -{: render} @@ -23,10 +23,14 @@ deploy () { git stash -u scp -r bin root@everytea.ru:~/whitetoad.ru/ scp -r pages root@everytea.ru:~/whitetoad.ru/ - scp -r static root@everytea.ru:~/whitetoad.ru/ + scp static/* root@everytea.ru:~/whitetoad.ru/static/ || true scp -r vendor root@everytea.ru:~/whitetoad.ru/ - scp templates.fnl root@everytea.ru:~/whitetoad.ru/ scp main.lua root@everytea.ru:~/whitetoad.ru/ + scp forms.fnl root@everytea.ru:~/whitetoad.ru/ + scp lib.fnl root@everytea.ru:~/whitetoad.ru/ + scp macros.fnl root@everytea.ru:~/whitetoad.ru/ + 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 } diff --git a/static/glasses.png b/static/glasses.png Binary files differnew file mode 100644 index 0000000..eaae9a4 --- /dev/null +++ b/static/glasses.png diff --git a/static/logo-bg.png b/static/logo-bg.png Binary files differnew file mode 100644 index 0000000..4762622 --- /dev/null +++ b/static/logo-bg.png diff --git a/static/logo-dark-bg.png b/static/logo-dark-bg.png Binary files differnew file mode 100644 index 0000000..36dbb9e --- /dev/null +++ b/static/logo-dark-bg.png diff --git a/static/style.css b/static/style.css index fb3fb3a..e31e6d1 100644 --- a/static/style.css +++ b/static/style.css @@ -1,97 +1,136 @@ body { background: #ffffff; margin: 4rem; - font-family: sans-serif, monospace, serif; - line-height: 1.4; + font-family: sans, monospace, sans-serif; + line-height: 1.5; +} + +@font-face { + font-family: "Bad Script"; + src: local("Bad Script"), + url: ("/static/fonts/BadScript-Regular.ttf") format ("opentype"); +} + +* { + box-sizing: border-box; +} + +a { + color: #529b57; + font-weight: bold; } nav { - margin-bottom: 0.5rem; + margin-bottom: 2rem; + font-size: 1.25rem; } -ol { - padding-left: 0; - margin-top: 0; +nav a.active { + text-decoration: none; + opacity: 0.5; } .container { display: flex; flex-wrap: wrap; - gap: 4rem; + gap: 1rem 2rem; } -.button-wrapper { - display: block; - margin-top: 0.5rem; - margin-bottom: 1.5rem; +.logo { + height: 95px; + position: relative; + display: flex; + gap: 1.25rem; + align-items: center; + margin-bottom: 1rem; } -.button { - border: 0.1875rem solid; - box-shadow: 1px 1px 0 0, 2px 2px 0 0, 3px 3px 0 0, 4px 4px 0 0; - color: #000000; - display: inline-block; - font-size: 1rem; - font-weight: bold; - padding: 0.75rem 2.5rem; - position: relative; - text-decoration: none; - text-transform: uppercase; - transition: box-shadow 0.2s, top 0.2s, left: 0.2s; +.logo::before { + background-image: url("/static/logo-bg.png"); + background-repeat: no-repeat; + background-size: cover; + position: absolute; + left: -50px; + top: -58px; + z-index: -1; + content: ""; + width: 215px; + height: 215px; } -.button:active { - box-shadow: 0px 0px 0px 0px; - top: 5px; - left: 5px; +.logo-glasses { + position: absolute; + top: -10px; + left: 35px; + width: 70px; + object-fit: contain; + pointer-events: none; } -.logo { - width: 227px; - height: 200px; +.logo img, +.logo a { + height: 95px; } h1, -h2 { - font-weight: 900; - font-style: italic; +h2, +h3, +h4, +h5, +h6 { + margin-top: 0; margin-bottom: 0.5rem; - line-height: 1.1; + font-family: sans; } h1 { - font-size: 2rem; + font-size: 1.75rem; + line-height: 1.2; } h2 { + font-size: 1.75rem; +} + +h3 { font-size: 1.375rem; } +.h5 { + font-size: 1.125rem; + font-style: normal; +} + +.side { + max-width: 23rem; +} + +.side h2 { + font-weight: 900; +} + .content { - max-width: 30rem; - font-size: 1.25rem; + max-width: 56rem; + width: 100%; } -.article + .article { - margin-top: 2rem; +.content h2 { + font-size: 3.5rem; + font-weight: 900; + line-height: 1.2; } p { margin-top: 0; } -.form-row { - display: block; - margin: 0.5rem 0; -} - .gallery { display: grid; grid-template-columns: repeat(9, 1fr); grid-template-rows: repeat(16, 5rem); grid-gap: 1rem; max-width: calc(100vw - 6rem); - width: 56rem; + width: 100%; } .gallery > div { @@ -139,52 +178,277 @@ p { object-fit: cover; } -@media screen and (max-width: 50rem) { - body { - margin: 2rem; - } +.shop-items { + display: flex; + gap: 2.5rem 1.5rem; + flex-wrap: wrap; +} - ol { - counter: ol-counter; - } +.shop-item { + width: 17.5rem; +} - ol li { - counter-increment: ol-counter; - } +.shop-item-not-published { + opacity: 0.5; +} - ol li::before { - display: block; - content: counter(ol-counter) "."; - } +.shop-item-imgs { + background-color: #00000011; + position: relative; + width: 17.5rem; + margin-bottom: 1rem; + aspect-ratio: 1; + overflow: hidden; +} + +.shop-item-img { + position: absolute; + left: 0; + top: 0; + object-fit: cover; + object-position: center; + height: 100%; + width: 100%; + opacity: 0; +} + +.shop-item-img:first-child { + opacity: 1; +} - ol li::marker { - content: ''; +.shop-item-img:hover { + opacity: 1; + left: 0 !important; + width: 100% !important; + z-index: 2 !important; +} + +.shop-item-price { + font-size: 1.25rem; + margin-bottom: 0.5rem; +} + +.shop-item-title { + font-size: 1.25rem; + margin-top: 0.25rem;; + margin-bottom: 0.25rem; +} + +.product-page-layout { + display: flex; + gap: 2rem; +} + +.product-page-layout > div { + flex: 50%; +} + +.product-page-imgs { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.product-page-img, +.product-page-img-mobile { + max-width: 100%; + width: 100%; +} + +.product-page-img-mobile { + display: none; +} + +.basket-item { + display: flex; + gap: 0.75rem; +} + +.basket-item { + margin-top: 1rem; +} + +.basket-item-title { + font-size: 1rem; +} + +.basket-item-image img { + width: 5.25rem; + height: 5.25rem; + object-fit: cover; + object-position: center; +} + +.basket-item-price { + font-size: 1rem; + font-style: italic; +} + +.basket-item-remove { + margin-top: 0.25rem; +} + +.form { + display: flex; + flex-direction: column; + gap: 2rem; + max-width: 35rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-row label { + display: block; + margin-bottom: 0.25rem; + font-weight: 600; +} + +.form-row input[type=checkbox] { + vertical-align: middle; + margin-left: 0; +} + +.form-row input[type=checkbox] + label { + display: inline; +} + +.form-row select, +.form-row textarea, +.form-row input:not([type=checkbox]) { + width: 100%; +} + +.form-row input:not([type=checkbox]), +.form-row select { + height: 1.75rem; +} + +.form-file-img { + width: 3.5rem; + height: 3.5rem; + object-fit: cover; + object-position: center; +} + +.form textarea { + min-height: 4rem; +} + +.form-required-marker { + font-size: 0.75rem; + opacity: 0.4; +} + +.form-error { + font-style: italic; + color: crimson; + font-size: 0.75rem; + font-weight: bold; + margin-top: 0.375rem; +} + +.form-subheader { + margin-bottom: -0.375rem; +} + +.form-help { + opacity: 0.4; + font-size: 0.75rem; + margin-top: 0.375rem; +} + +.form-error + .form-help { + margin-top: 0.25rem; +} + +.d-flex { + display: flex; +} + +.gap-1 { + gap: 1rem; +} + +.d-inline-block { + display: inline-block; +} + +.mb-0 { + margin-bottom: 0; +} + +.mb-0-5 { + margin-bottom: 0.5rem; +} + +.mb-1 { + margin-bottom: 1rem; +} + +.mb-1-5 { + margin-bottom: 1.5rem; +} + +.mb-2 { + margin-bottom: 2rem; +} + +@media screen and (max-width: 50rem) { + body { + margin: 2rem; } - .content { + .side { max-width: 100%; } + .content h2 { + font-size: 3rem; + } + .container { - gap: 2rem; + gap: 1rem; } .gallery { display: flex; flex-direction: column; gap: 1rem; - max-width: calc(100vw - 2rem); } .gallery img, .gallery video { position: static; } + + .product-page-layout { + flex-direction: column; + } + + .product-page-img-mobile { + display: block; + } + + .product-page-imgs > *:first-child { + display: none; + } +} + +@media screen and (max-width: 41rem) { + .shop-item, + .shop-item-imgs, + .shop-item-img { + width: 100%; + } } @media (prefers-color-scheme: dark) { body { - background-color: #000000; + background-color: #211e1c; color: #ffffff; } @@ -196,7 +460,11 @@ p { background-color: #ffffff22; } - .button { - color: #ffffff; + .shop-item-imgs { + background-color: #ffffff22; + } + + .logo::before { + background-image: url("/static/logo-dark-bg.png"); } } diff --git a/templates.fnl b/templates.fnl index f19c2b0..f4ff5cc 100644 --- a/templates.fnl +++ b/templates.fnl @@ -1,24 +1,69 @@ +(import-macros {:compile-html <>} :macros) +(local lib (require :lib)) + (fn read-file [file] (with-open [f (io.open file "r")] (f:read :*all))) -(fn base-template [content] - [:html {:lang "ru-RU"} - [:head {} - [:title {} "Чайная «Белая жаба» — маленькая уютная чайная в Омске"] - [:meta {:charset "utf-8"}] - [:meta {:name "viewport" - :content (.. "width=device-width,initial-scale=1," - "minimum-scale=1.0,maximum-scale=5.0")}] - [:meta {:name "description" - :content (.. "Маленькая уютная чайная для своих: " - "Омск, ул. Пушкина, д. 133/9, этаж 2. " - "Посещение по предварительной договоренности, " - "стоимость 500 рублей с человека.")}] - [:style {} [:NO-ESCAPE (read-file "static/style.css")]] - [:link {:rel "icon" :href "https://whitetoad.ru/static/favicon.svg" - :type "image/svg+xml"}]] - [:body {} - [:main {:class "container"} (table.unpack content)]]]) +(fn base [content] + (<> + [:html {:lang "ru-RU"} + [:head {} + [:title {} "«Белая жаба» — маленькая уютная чайная в Омске"] + [:meta {:charset "utf-8"}] + [:meta {:name "viewport" + :content (.. "width=device-width,initial-scale=1," + "minimum-scale=1.0,maximum-scale=5.0")}] + [:meta {:name "description" + :content (.. "Маленькая уютная чайная: " + "Омск, ул. Пушкина, д. 133/9, этаж 2. " + "Посещение по предварительной договоренности, " + "стоимость 500 рублей с человека.")}] + [:style {} [:NO-ESCAPE (read-file "static/style.css")]] + [:link {:rel "icon" :href "https://whitetoad.ru/static/favicon.svg" + :type "image/svg+xml"}]] + [:body {} + [:main {:class "container"} (table.concat content)]]])) + +(fn header [current-path authenticated?] + (local logo + (<> + [:img {:src "/static/logo.svg" + :alt "Белая жаба в мультяшном стиле с чайником на голове"}])) + + (<> + [:article {:class "article"} + [:div {:class "logo"} + (if authenticated? + (<> [:img {:class "logo-glasses" :src "/static/glasses.png" + :alt "Солнцезащитные очки"}]) + "") + (if (~= current-path "") + (<> [:a {:href "/" :class "d-inline-block"} logo]) + logo) + [:h1 {} [:NO-ESCAPE "Чайная<br>«Белая жаба»"]]] + [:nav {} + [:a {:href "/shop" :class (if (lib.starts-with? current-path "/shop") "active" "")} + "магазин"] + [:span {} "~"] + [:a {:href "https://t.me/whitetoadtea"} "телеграм"] + [:span {} "~"] + [:a {:href "https://vk.com/whitetoadtea"} "вконтакте"]]])) + +(fn basket-item [item redirect-url] + (<> + [:div {:class "basket-item"} + [:div {:class "basket-item-image"} + [:img {:src (.. "/static/files/" (. item :images 1)) :alt item.title}]] + [:div {} + [:strong {:class "basket-item-title"} item.title] + [:div {:class "basket-item-price"} + (.. item.quantity " грамм за " + (* item.price-per 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"} "⨯ убрать из корзины"]]]]])) -{: base-template} +{: base : header : basket-item} diff --git a/test.fnl b/test.fnl new file mode 100644 index 0000000..df0df6d --- /dev/null +++ b/test.fnl @@ -0,0 +1,113 @@ +(import-macros {:compile-html <>} :macros) +(local fennel (require :vendor.fennel)) + +(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 + (<> [:img {:class "logo" :src "/static/logo.svg" + :alt "Белая жаба в мультяшном стиле с чайником на голове"}])) + + (<> [:article {:class "article"} + (if (~= current-path "") (<> [: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 + (<> [:div (fn [] {:huemoe nil :hello "world" :required true}) "whatever"])) + +; (macrodebug +; (<> [:div (fn [] {:hello "world" :required true}) "whatever"])) + +; (macrodebug +; (<> +; [:div {:class "side"} +; (unpack [])])) + +; (macrodebug +; (<> +; [:div {:class "side"} +; [:article {:class "article"} +; (header "") +; [:h2 {} "Адрес"] +; [:p {} "test!"]]])) + +; (print (<> +; [:img {:class "side"}])) + +; (local hello {:world "test"}) + +; (print +; (fennel.view +; [(<> [:div {:class "first"} "11111"]) +; (<> [:div {:class "second"} "22222"])])) + +; (print (<> +; [: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 +; (<> +; [: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"} "Подписаться"]]]]]])) diff --git a/var/.gitkeep b/var/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/var/.gitkeep diff --git a/vendor/html.fnl b/vendor/html.fnl deleted file mode 100644 index 753bbb9..0000000 --- a/vendor/html.fnl +++ /dev/null @@ -1,32 +0,0 @@ -(local entity-replacements {"&" "&" ; must be first! - "<" "<" - ">" ">" - "\"" """}) - -(local entity-search - (.. "[" (table.concat (icollect [k (pairs entity-replacements)] k)) "]")) - -(fn escape [s] - (assert (= (type s) :string)) - (s:gsub entity-search entity-replacements)) - -(fn tag [tag-name attrs self-closing?] - (assert (= (type attrs) "table") (.. "Missing attrs table: " tag-name)) - (let [attr-str (table.concat (icollect [k v (pairs attrs)] - (if (= v true) k - (.. k "=\"" (escape v)"\""))) " ")] - (.. "<" tag-name " " attr-str (if self-closing? " />" ">")))) - -(fn render [document allow-no-escape?] - (if (= (type document) :string) - (escape document) - (and allow-no-escape? (= (. document 1) :NO-ESCAPE)) - (. document 2) - (let [[tag-name attrs & body] document - self-closing? (= 0 (# body))] - (.. (tag tag-name attrs self-closing?) - (table.concat (icollect [_ element (ipairs body)] - (render element allow-no-escape?)) " ") - (if (not self-closing?) (.. "</" tag-name ">") ""))))) - -{ :render render } |
