summaryrefslogtreecommitdiff
path: root/pages/shop
diff options
context:
space:
mode:
Diffstat (limited to 'pages/shop')
-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
8 files changed, 533 insertions, 0 deletions
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}