summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorunwox <me@unwox.com>2025-08-31 17:51:57 +0600
committerunwox <me@unwox.com>2025-09-04 20:14:11 +0600
commit66c51b0e714fa8a1c80784108191270babc8525e (patch)
tree0640549f522092096d83c78b9be9b1fa4a03929e
parentd8039a77d582f696ab98b2a6d02ce924fbacfa41 (diff)
implement shop
-rw-r--r--.gitignore2
-rw-r--r--bin/serve.fnl231
-rw-r--r--dicts.fnl22
-rw-r--r--forms.fnl288
-rw-r--r--lib.fnl222
-rw-r--r--macros.fnl95
-rw-r--r--pages/auth.fnl68
-rw-r--r--pages/index.fnl130
-rw-r--r--pages/shop/_product/edit.fnl75
-rw-r--r--pages/shop/_product/index.fnl83
-rw-r--r--pages/shop/add.fnl83
-rw-r--r--pages/shop/cart/add.fnl38
-rw-r--r--pages/shop/cart/remove.fnl23
-rw-r--r--pages/shop/index.fnl155
-rw-r--r--pages/shop/order.fnl61
-rw-r--r--pages/shop/success.fnl15
-rw-r--r--pages/staff/before-leaving.fnl36
-rwxr-xr-xrun.sh8
-rw-r--r--static/glasses.pngbin0 -> 14633 bytes
-rw-r--r--static/logo-bg.pngbin0 -> 74788 bytes
-rw-r--r--static/logo-dark-bg.pngbin0 -> 31159 bytes
-rw-r--r--static/style.css402
-rw-r--r--templates.fnl83
-rw-r--r--test.fnl113
-rw-r--r--var/.gitkeep0
-rw-r--r--vendor/html.fnl32
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}
diff --git a/lib.fnl b/lib.fnl
new file mode 100644
index 0000000..cb437a2
--- /dev/null
+++ b/lib.fnl
@@ -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 (.. "(&nbsp;" v ") ") "%1&nbsp;")
+ (string.gsub (.. "(%s" v ") ") " %1&nbsp;")
+ (string.gsub (.. "^(" v ") ") "%1&nbsp;")))))
+ result)
+
+(fn test-improve-typography []
+ (assert (= (improve-typography "Я лежу на пляжу!")
+ "Я&nbsp;лежу на&nbsp;пляжу!"))
+ (assert (= (improve-typography "500 рублей мы сняли со счета!")
+ "500&nbsp;рублей мы&nbsp;сняли со&nbsp;счета!"))
+ (assert (= (improve-typography "500 рублей мы
+ сняли со счета!")
+ "500&nbsp;рублей мы&nbsp;сняли со&nbsp;счета!")))
+
+(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 {"&" "&amp;" ; must be first!
+ "<" "&lt;"
+ ">" "&gt;"
+ "\"" "&quot;"})
+
+(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 (.. "(&nbsp;" v ") ") "%1&nbsp;")
- (string.gsub (.. "(%s" v ") ") " %1&nbsp;")
- (string.gsub (.. "^(" v ") ") "%1&nbsp;")))))
- result)
-
-(fn test-improve-typography []
- (assert (= (improve-typography "Я лежу на пляжу!")
- "Я&nbsp;лежу на&nbsp;пляжу!"))
- (assert (= (improve-typography "500 рублей мы сняли со счета!")
- "500&nbsp;рублей мы&nbsp;сняли со&nbsp;счета!"))
- (assert (= (improve-typography "500 рублей мы
- сняли со счета!")
- "500&nbsp;рублей мы&nbsp;сняли со&nbsp;счета!")))
(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 "Чайная комната «Белая&nbsp;жаба»"]]
- [: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 "Чайная «Белая&nbsp;жаба»"]]
- [: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}
diff --git a/run.sh b/run.sh
index 735e815..5a19098 100755
--- a/run.sh
+++ b/run.sh
@@ -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
new file mode 100644
index 0000000..eaae9a4
--- /dev/null
+++ b/static/glasses.png
Binary files differ
diff --git a/static/logo-bg.png b/static/logo-bg.png
new file mode 100644
index 0000000..4762622
--- /dev/null
+++ b/static/logo-bg.png
Binary files differ
diff --git a/static/logo-dark-bg.png b/static/logo-dark-bg.png
new file mode 100644
index 0000000..36dbb9e
--- /dev/null
+++ b/static/logo-dark-bg.png
Binary files differ
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>«Белая&nbsp;жаба»"]]]
+ [: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 (.. "(&nbsp;" v ") ") "%1&nbsp;")
+ (string.gsub (.. "(%s" v ") ") " %1&nbsp;")
+ (string.gsub (.. "^(" v ") ") "%1&nbsp;")))))
+ 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 {} "Чайная комната «Белая&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
+ (<> [: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 {"&" "&amp;" ; must be first!
- "<" "&lt;"
- ">" "&gt;"
- "\"" "&quot;"})
-
-(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 }