diff options
Diffstat (limited to 'pages/shop')
| -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 |
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} |
