diff --git a/gulpfile.js b/gulpfile.js index b84a6211f..6880b767c 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -108,6 +108,7 @@ function css() { require('postcss-calc')(), require('postcss-color-function')(), require('postcss-discard-comments')(), + require('postcss-inherit'), require('postcss-inline-svg')(), require('autoprefixer')({browsers: ['last 3 versions']}), /* require('postcss-reporter')(), */ diff --git a/package.json b/package.json index bd55af762..91512654a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "basscss-ui-utility-groups": "^1.0.1", "jsqr": "1.1.0", "muuri-react": "^3.1.6", - "normalize.css": "^4.2.0" + "normalize.css": "^4.2.0", + "postcss-inherit": "^4.1.0" }, "devDependencies": { "autoprefixer": "^6.3.1", diff --git a/project.clj b/project.clj index 3da306d69..fce7813f8 100644 --- a/project.clj +++ b/project.clj @@ -42,7 +42,9 @@ [com.cemerick/url "0.1.1"] [overtone/at-at "1.2.0"] [camel-snake-kebab "0.4.0"] - [org.clojure/spec.alpha "0.2.176"]] + [org.clojure/spec.alpha "0.2.176"] + [meander/epsilon "0.0.650"] + [markdown-clj "1.10.6"]] :repositories [["private" {:url "s3p://mayvenn-dependencies/releases/"}]] :plugins [[s3-wagon-private "1.3.1"] [lein-cljsbuild "1.1.7"] diff --git a/resources/css/app.css b/resources/css/app.css index 70c898355..a1aef5785 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -82,6 +82,7 @@ /* Third-party Overrides */ +@import 'custom-contentful.css'; @import 'custom-uploadcare.css'; @import 'custom-yotpo.css'; @import 'custom-kustomer.css'; diff --git a/resources/css/custom-contentful.css b/resources/css/custom-contentful.css new file mode 100644 index 000000000..bbde00d58 --- /dev/null +++ b/resources/css/custom-contentful.css @@ -0,0 +1,58 @@ +@import 'basscss-typography'; +@import 'custom-typography.css'; +@import 'basscss-margin'; +@import 'custom-margin.css'; +@import 'basscss-padding'; +@import 'custom-padding.css'; +@import 'basscss-border'; +@import 'custom-border.css'; +@import 'basscss-addons/modules/colors'; +@import 'custom-colors.css'; +@import 'basscss-addons/modules/border-colors'; +@import 'custom-border-colors.css'; + +.content-markdown a:link { + @inherit: .p-color, .button-font-2, .border-bottom, .border-width-2, .border-p-color; +} +.content-markdown { + font: 13px/18px var(--font-family-proxima); + line-height: 26px; +} +.content-markdown ul, .content-markdown ol { @inherit: .mb2, .content-2; } +.content-markdown p { @inherit: .content-2, .mb2; } +.content-markdown li { @inherit: .content-2, .my1; } +.content-markdown h1 { @inherit: .title-2.canela, .my3; } +.content-markdown h2 { @inherit: .title-2.canela, .my3; } +.content-markdown h3 { @inherit: .title-3.canela, .my3; } +.content-markdown b { @inherit: .bold; } +.content-markdown table { + border-collapse: collapse; + table-layout: fixed; + margin: 10px 0; + width: 100%; + border: 2px solid var(--gray-mask); +} +.content-markdown table tr { + border-bottom: 1px solid var(--cool-gray); +} +.content-markdown table th { + @inherit: .content-3, .p1, .bold; + background: var(--cool-gray); +} +.content-markdown table td:first-child { + border-left: 0; +} +.content-markdown table td { + @inherit: .p1; + border-left: 1px solid var(--cool-gray); + margin: 0; + vertical-align: top; +} + +:root { + --gray-mask: rgba(204, 204, 204, 0.1); + --cool-gray: #eeefef; + --medium-font-weight: 400; + --font-weight: var(--medium-font-weight); + --font-family-proxima: 'Proxima Nova', Arial, sans-serif; +} \ No newline at end of file diff --git a/resources/gql/all_static_pages.gql b/resources/gql/all_static_pages.gql new file mode 100644 index 000000000..f20781330 --- /dev/null +++ b/resources/gql/all_static_pages.gql @@ -0,0 +1,9 @@ +query($preview: Boolean) { + staticPageCollection(preview: $preview) { + items { + sys { id } + path + title + } + } +} \ No newline at end of file diff --git a/resources/gql/static_page.gql b/resources/gql/static_page.gql new file mode 100644 index 000000000..df5fa3369 --- /dev/null +++ b/resources/gql/static_page.gql @@ -0,0 +1,122 @@ +fragment Subcontent on Entry { + sys { + id + } + __typename + ...on Markdown { + title + content + } + ...on Html { + html + } + ...on Paragraph { + title + textAlignment + text { + json + links { + entries { + inline { + sys { + id + } + # graphql does not allow recursive queries + # ...Content + } + block { + sys { + id + } + # graphql does not allow recursive queries + # ...Content + } + } + assets { + # hyperlink {} + block { + sys { id } + title + url + width + height + } + } + } + } + } +} +fragment Content on Entry { + sys { + id + } + __typename + ...on Markdown { + title + content + } + ...on Html { + html + } + ...on Paragraph { + title + textAlignment + text { + json + links { + entries { + inline { + # graphql does not allow recursive queries - so we can step one down + ...Subcontent + } + block { + # graphql does not allow recursive queries - so we can step one down + ...Subcontent + } + } + assets { + # hyperlink {} + block { + sys { id } + title + url + width + height + } + } + } + } + } +} + +query($preview: Boolean, $path: String) { + staticPageCollection(preview: $preview, limit: 1, where: {path: $path}) { + items { + path + title + content { + json + links { + entries { + inline { + ...Content + } + block { + ...Content + } + } + assets { + # hyperlink {} + block { + sys { id } + url + title + width + height + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src-cljc/storefront/components/content.cljc b/src-cljc/storefront/components/content.cljc index 81af53e7c..a9c55e7bc 100644 --- a/src-cljc/storefront/components/content.cljc +++ b/src-cljc/storefront/components/content.cljc @@ -2,8 +2,10 @@ (:require [storefront.component :as component :refer [defcomponent]] [storefront.keypaths :as keypaths])) -(defcomponent component [{:keys [content]} owner opts] - [:div {:dangerouslySetInnerHTML {:__html content}}]) +(defcomponent component [{:keys [content static-content-id]} owner opts] + [:div.mx-960.mx-auto + {:id (str "content-" static-content-id) + :dangerouslySetInnerHTML {:__html content}}]) (defn query [data] (let [sms-number (get-in data keypaths/sms-number)] diff --git a/src-cljc/storefront/routes.cljc b/src-cljc/storefront/routes.cljc index 654778f3e..00592c512 100644 --- a/src-cljc/storefront/routes.cljc +++ b/src-cljc/storefront/routes.cljc @@ -14,15 +14,16 @@ (read-string (name value))) (def static-page-routes - {"/guarantee" (edn->bidi events/navigate-content-guarantee) - "/help" (edn->bidi events/navigate-content-help) - "/about-us" (edn->bidi events/navigate-content-about-us) - "/policy/privacy" (edn->bidi events/navigate-content-privacy) - "/policy/tos" (edn->bidi events/navigate-content-tos) - "/ugc-usage-terms" (edn->bidi events/navigate-content-ugc-usage-terms) - "/voucher-terms" (edn->bidi events/navigate-content-voucher-terms) - "/program-terms" (edn->bidi events/navigate-content-program-terms) - "/our-hair" (edn->bidi events/navigate-content-our-hair)}) + {"/guarantee" (edn->bidi events/navigate-content-guarantee) + "/help" (edn->bidi events/navigate-content-help) + "/about-us" (edn->bidi events/navigate-content-about-us) + "/policy/privacy" (edn->bidi events/navigate-content-privacy) + "/policy/privacy/v1" (edn->bidi events/navigate-content-privacy) + "/policy/tos" (edn->bidi events/navigate-content-tos) + "/ugc-usage-terms" (edn->bidi events/navigate-content-ugc-usage-terms) + "/voucher-terms" (edn->bidi events/navigate-content-voucher-terms) + "/program-terms" (edn->bidi events/navigate-content-program-terms) + "/our-hair" (edn->bidi events/navigate-content-our-hair)}) (def static-api-routes ["/static" static-page-routes]) diff --git a/src-cljs/storefront/frontend_effects.cljs b/src-cljs/storefront/frontend_effects.cljs index c8c2369c0..eecfa9516 100644 --- a/src-cljs/storefront/frontend_effects.cljs +++ b/src-cljs/storefront/frontend_effects.cljs @@ -305,6 +305,12 @@ (get-in app-state keypaths/static-id)) (api/get-static-content event))) +(defmethod effects/perform-effects events/navigate-static-page + [_ _ _ _ app-state] + (when-not (= static-content-id + (get-in app-state keypaths/static-id)) + (api/get-static-content event))) + (defmethod effects/perform-effects events/navigate-content-about-us [_ _ _ _ app-state] (wistia/load)) diff --git a/src/storefront/config.clj b/src/storefront/config.clj index 360f1e43f..0c8584c3f 100644 --- a/src/storefront/config.clj +++ b/src/storefront/config.clj @@ -83,18 +83,32 @@ (catch java.lang.IllegalArgumentException e "unknown"))) +;; Macro? +(defn- if-dev-else + "Use dev-value if in env, otherwise use prod-value" + [env dev-value prod-value] + (if (#{"production" "acceptance"} (env :environment)) + prod-value + dev-value)) + +(def ^:private minute (* 60 1000)) + (def default-config {:server-opts {:port 3006} :client-version client-version - :contentful-config {:cache-timeout 120000 - :endpoint "https://cdn.contentful.com"} + :contentful-config {:endpoint "https://cdn.contentful.com" + :graphql-endpoint "https://graphql.contentful.com" + :env-id "master"} :logging {:system-name "storefront.system"}}) (defn env-config [] {:environment (env :environment) :bugsnag-token (env :bugsnag-token) :welcome-config {:url (env :welcome-url)} - :contentful-config {:api-key (env :contentful-content-delivery-api-key) - :space-id (env :contentful-space-id)} + :contentful-config {:cache-timeout (* (if-dev-else env 4 15) minute) + :static-page-fetch-interval (* (if-dev-else env 5 30) minute) + :api-key (env :contentful-content-delivery-api-key) + :preview-api-key (env :contentful-content-delivery-preview-api-key) + :space-id (env :contentful-space-id)} :launchdarkly-config {:sdk-key (env :launchdarkly-sdk-key)} :storeback-config {:endpoint (env :storeback-endpoint) :internal-endpoint (or diff --git a/src/storefront/handler.clj b/src/storefront/handler.clj index 2708ede1e..648871431 100644 --- a/src/storefront/handler.clj +++ b/src/storefront/handler.clj @@ -28,6 +28,7 @@ [spice.selector :as selector] storefront.ugc stylist-directory.keypaths + [storefront.safe-hiccup :refer [html5]] [storefront.accessors.auth :as auth] [storefront.accessors.categories :as accessors.categories] [storefront.accessors.experiments :as experiments] @@ -44,6 +45,7 @@ [storefront.keypaths :as keypaths] [storefront.routes :as routes] [storefront.system.contentful :as contentful] + [storefront.system.contentful.static-page :as static-pages] [storefront.transitions :as transitions] [storefront.views :as views] storefront.accessors.contentful @@ -70,19 +72,19 @@ (catch NumberFormatException _ nil))) -(defn render-static-page [template] - (template/eval template {:url assets/path})) - -(defn static-page [[navigate-kw content-kw & static-content-id]] - (when (= [navigate-kw content-kw] events/navigate-content) +(defn static-page [[navigate-kw content-kw & static-content-id] static-pages-repo req] + ;; TODO(jeff): use an env var for preview parameter value & use constant-time= instead of = + (if-let [src (static-pages/content-for static-pages-repo (:uri req) (= "N60sUd" (get (:query-params req) "preview")))] {:id static-content-id - :content (->> static-content-id - (map name) - (string/join "-") - (format "public/content/%s.html") - io/resource - slurp - render-static-page)})) + :content src} + (when (= [navigate-kw content-kw] events/navigate-content) + (when-let [local-file (->> static-content-id + (map name) + (string/join "-") + (format "public/content/%s.html") + io/resource)] + {:id static-content-id + :content (-> local-file slurp (template/eval {:uri assets/path}))})))) (defn storefront-site-defaults [environment] @@ -274,7 +276,7 @@ ([req keypath default-value] (get-in req (concat [:state] keypath) default-value))) -(defn wrap-set-initial-state [h environment] +(defn wrap-set-initial-state [h environment static-pages-repo] (fn [req] (let [nav-message (:nav-message req) [nav-event _params] nav-message @@ -285,7 +287,7 @@ (assoc-in-req-state keypaths/scheme (name (:scheme req))) (assoc-in-req-state keypaths/navigation-message nav-message) (assoc-in-req-state keypaths/navigation-uri nav-uri) - (assoc-in-req-state keypaths/static (static-page nav-event)) + (assoc-in-req-state keypaths/static (static-page nav-event static-pages-repo req)) (assoc-in-req-state keypaths/environment environment) (update-in-req-state [] experiments/determine-features)))))) @@ -493,7 +495,7 @@ (assoc-in-req-state keypaths/features ["stylist-results-test"]))))) ;;TODO Have all of these middleswarez perform event transitions, just like the frontend -(defn wrap-state [routes {:keys [storeback-config welcome-config contentful launchdarkly environment]}] +(defn wrap-state [routes {:keys [storeback-config welcome-config contentful static-pages-repo launchdarkly environment]}] (-> routes (wrap-add-feature-flags launchdarkly) (wrap-set-cms-cache contentful) @@ -502,7 +504,7 @@ (wrap-set-user) (wrap-set-welcome-url welcome-config) wrap-affiliate-initial-login-landing-navigation-message - (wrap-set-initial-state environment))) + (wrap-set-initial-state environment static-pages-repo))) (defn wrap-redirect-aladdin [h environment] @@ -966,12 +968,12 @@ (util.response/redirect "/checkout/payment?error=quadpay-invalid-state") (util.response/redirect "/checkout/processing"))))) -(defn static-routes [_] - (fn [{:keys [uri] :as _req}] +(defn static-routes [{:keys [static-pages-repo]}] + (fn [{:keys [uri] :as req}] ;; can't use (:nav-message req) because routes/static-api-routes are not ;; included in routes/app-routes (let [{nav-event :handler} (bidi/match-route routes/static-api-routes uri)] - (some-> nav-event routes/bidi->edn static-page :content ->html-resp)))) + (some-> nav-event routes/bidi->edn (static-page static-pages-repo req) :content ->html-resp)))) (defn wrap-filter-params "Technically an invalid value, but query-params could generate this value diff --git a/src/storefront/system.clj b/src/storefront/system.clj index 64ab453b1..c4a0b6796 100644 --- a/src/storefront/system.clj +++ b/src/storefront/system.clj @@ -7,14 +7,16 @@ [storefront.handler :refer [create-handler]] [storefront.jetty :as jetty] [storefront.system.contentful :as contentful] + [storefront.system.scheduler :as scheduler] + [storefront.system.contentful.static-page :as static-page] [spice.logger.core :as logger] [tocsin.core :as tocsin])) -(defrecord AppHandler [logger exception-handler storeback-config welcome-config environment client-version] +(defrecord AppHandler [logger exception-handler storeback-config welcome-config environment client-version static-pages-repo] component/Lifecycle (start [c] (assoc c :handler (create-handler (dissoc c :handler)))) - (stop [c] c)) + (stop [c] (dissoc c :handler))) (defn exception-handler [bugsnag-token environment] (fn [e] @@ -30,6 +32,11 @@ (defn system-map [config] (component/system-map :logger (logger/create-logger (config :logging)) + :scheduler (scheduler/->Scheduler nil nil nil) + :static-pages-repo (static-page/->Repository + (merge (:contentful-config config) + (select-keys config [:environment])) + nil nil) :contentful (contentful/map->ContentfulContext (merge (:contentful-config config) (select-keys config [:environment]))) :launchdarkly (feature-flags/map->LaunchDarkly (select-keys config [:launchdarkly-config])) @@ -43,9 +50,11 @@ :exception-handler (exception-handler (config :bugsnag-token) (config :environment)))) (def dependency-map - {:app-handler [:logger :exception-handler :contentful :launchdarkly :sitemap-cache] - :contentful [:logger :exception-handler] - :embedded-server {:app :app-handler}}) + {:app-handler [:logger :exception-handler :contentful :launchdarkly :sitemap-cache :static-pages-repo] + :contentful [:logger :exception-handler :scheduler] + :static-pages-repo [:scheduler] + :scheduler [:logger :exception-handler] + :embedded-server {:app :app-handler}}) (defn create-system ([] (create-system {})) diff --git a/src/storefront/system/contentful.clj b/src/storefront/system/contentful.clj index 021f2c9d8..c58146e61 100644 --- a/src/storefront/system/contentful.clj +++ b/src/storefront/system/contentful.clj @@ -2,7 +2,7 @@ (:require [clojure.walk :as walk] [com.stuartsierra.component :as component] [lambdaisland.uri :as uri] - [overtone.at-at :as at-at] + [storefront.system.scheduler :as scheduler] [storefront.utils :as utils] [ring.util.response :as util.response] [spice.date :as date] @@ -157,88 +157,81 @@ :or {item-tx-fn identity collection-tx-fn identity} :as content-params} attempt-number] - (try - (when (<= attempt-number 2) - (let [{:keys [status body]} (contentful-request - contentful - (merge - {"content_type" (name content-type)} - (when exists - (reduce - (fn [m field] - (assoc m (str field "[exists]") true)) - {} - exists)) - (when select - {"select" (string/join "," select)}) - (when latest? - {"limit" 1 - :order (str "-fields." env-param) - (str "fields." env-param "[lte]") (date/to-iso (date/now))})))] - (if (and status (<= 200 status 299)) - (swap! cache merge - (cond - (contains? #{:mayvennMadePage :advertisedPromo} content-type) - (some-> body extract resolve-all walk/keywordize-keys (select-keys [content-type])) + (when (<= attempt-number 2) + (let [{:keys [status body]} (contentful-request + contentful + (merge + {"content_type" (name content-type)} + (when exists + (reduce + (fn [m field] + (assoc m (str field "[exists]") true)) + {} + exists)) + (when select + {"select" (string/join "," select)}) + (when latest? + {"limit" 1 + :order (str "-fields." env-param) + (str "fields." env-param "[lte]") (date/to-iso (date/now))})))] + (if (and status (<= 200 status 299)) + (swap! cache merge + (cond + (contains? #{:mayvennMadePage :advertisedPromo} content-type) + (some-> body extract resolve-all walk/keywordize-keys (select-keys [content-type])) - (= :homepage content-type) - (some->> body - condense-items-with-includes - walk/keywordize-keys - (maps/index-by primary-key-fn) - (assoc {} content-type)) + (= :homepage content-type) + (some->> body + condense-items-with-includes + walk/keywordize-keys + (maps/index-by primary-key-fn) + (assoc {} content-type)) - (= :ugc-collection content-type) - (some->> body - resolve-all-collection - (mapv extract-fields) - walk/keywordize-keys - (mapv item-tx-fn) - (maps/index-by primary-key-fn) - collection-tx-fn - (assoc {} content-type)) + (= :ugc-collection content-type) + (some->> body + resolve-all-collection + (mapv extract-fields) + walk/keywordize-keys + (mapv item-tx-fn) + (maps/index-by primary-key-fn) + collection-tx-fn + (assoc {} content-type)) - (= :faq content-type) - (some->> body - resolve-all-collection - (mapv extract-fields) - walk/keywordize-keys - (mapv (juxt :faq-section :questions-answers)) - (map (fn [[faq-section questions-answers]] - {:slug faq-section - :question-answers (map - (fn [{:keys [question answer]}] - {:question {:text question} - :answer (format-answer answer)}) - questions-answers)})) - (maps/index-by primary-key-fn) - (assoc {} content-type)) + (= :faq content-type) + (some->> body + resolve-all-collection + (mapv extract-fields) + walk/keywordize-keys + (mapv (juxt :faq-section :questions-answers)) + (map (fn [[faq-section questions-answers]] + {:slug faq-section + :question-answers (map + (fn [{:keys [question answer]}] + {:question {:text question} + :answer (format-answer answer)}) + questions-answers)})) + (maps/index-by primary-key-fn) + (assoc {} content-type)) - :else - (some->> body - resolve-all-collection - (mapv extract-fields) - walk/keywordize-keys - (mapv item-tx-fn) - (maps/index-by primary-key-fn) - collection-tx-fn - (assoc {} content-type)))) + :else + (some->> body + resolve-all-collection + (mapv extract-fields) + walk/keywordize-keys + (mapv item-tx-fn) + (maps/index-by primary-key-fn) + collection-tx-fn + (assoc {} content-type)))) - (do-fetch-entries contentful content-params (inc attempt-number))))) - (catch Throwable t - ;; Ideally, we should never get here, but at-at halts all polls that throw exceptions silently. - ;; This simply reports it and lets the polling continue - (exception-handler t) - (logger :error t))))) + (do-fetch-entries contentful content-params (inc attempt-number))))))) (defprotocol CMSCache (read-cache [_] "Returns a map representing the CMS cache")) -(defrecord ContentfulContext [logger exception-handler environment cache-timeout api-key space-id endpoint] +(defrecord ContentfulContext [logger exception-handler environment cache-timeout api-key space-id endpoint scheduler] component/Lifecycle (start [c] - (let [pool (at-at/mk-pool) - production? (= environment "production") + (let [production? (= environment "production") cache (atom {}) env-param (if production? "production" @@ -281,17 +274,12 @@ (assoc m :all-looks))) :latest? false}]] (doseq [content-params content-type-parameters] - (at-at/interspaced cache-timeout - #(do-fetch-entries (assoc c :cache cache :env-param env-param) content-params) - pool - :desc (str "poller for " (:content-type content-params)))) - (println "Pool polling status at start: " (at-at/show-schedule pool)) - (assoc c - :pool pool - :cache cache))) + (scheduler/every scheduler cache-timeout + (str "poller for " (:content-type content-params)) + #(do-fetch-entries (assoc c :cache cache :env-param env-param) content-params))) + (assoc c :cache cache))) (stop [c] - (when (:pool c) (at-at/stop-and-reset-pool! (:pool c))) - (dissoc c :cache :pool)) + (dissoc c :cache)) CMSCache (read-cache [c] (deref (:cache c)))) diff --git a/src/storefront/system/contentful/graphql.clj b/src/storefront/system/contentful/graphql.clj new file mode 100644 index 000000000..75f85dcea --- /dev/null +++ b/src/storefront/system/contentful/graphql.clj @@ -0,0 +1,31 @@ +(ns storefront.system.contentful.graphql + (:require [clojure.java.io :as io] + [clojure.string :as string] + [tugboat.core :as tugboat] + [cheshire.core :as json])) + +(defn- request + "Please use [[query]] when possible" + [{:keys [graphql-endpoint preview-api-key api-key space-id env-id]} gql variables] + (try + (tugboat/request {:endpoint graphql-endpoint} + :post (str "/content/v1/spaces/" space-id "/environments/" env-id) + {:socket-timeout 10000 + :conn-timeout 10000 + :as :json + :headers {"Authorization" (str "Bearer " (if (get variables "preview") + preview-api-key + api-key)) + "Content-Type" "application/json"} + :body (json/generate-string {:query (str gql) + :variables variables})}) + (catch java.io.IOException ioe + nil))) + +(defn query [contentful-ctx file variables] + (request contentful-ctx (slurp (io/resource (str "gql/" file))) variables)) + + +(comment + (query (:contentful dev-system/the-system) "static_page.gql" {"$preview" false "$path" "/policy/privacy"}) + (query (:contentful dev-system/the-system) "all_static_pages.gql" {"$preview" false})) \ No newline at end of file diff --git a/src/storefront/system/contentful/static_page.clj b/src/storefront/system/contentful/static_page.clj new file mode 100644 index 000000000..f0ed749f8 --- /dev/null +++ b/src/storefront/system/contentful/static_page.clj @@ -0,0 +1,187 @@ +(ns storefront.system.contentful.static-page + (:require [storefront.system.contentful.graphql :as gql] + [storefront.system.scheduler :as scheduler] + [storefront.component :refer [normalize-element]] + [hiccup.core :refer [html]] + [spice.maps :as maps] + [meander.epsilon :as m] + [markdown.core :as markdown] + [clojure.string :as string] + [com.stuartsierra.component :refer [Lifecycle]])) + +(defn- content-map [c] + (apply maps/deep-merge + (m/search c (m/and (m/$ {:sys {:id ?id} :as ?m}) + (m/guard (not (:type (:sys ?m))))) + {?id ?m}))) + +(defn- content-html + "Converts contentful linked structure into a hiccup-like format for page rendering" + ([c exception-handler] (content-html c (content-map c) exception-handler)) + ([c id->entry exception-handler] + (m/match c + ;; content types + {:__typename "Markdown" :title ?title :content ?content} + [:div + (when (pos? (count ?title)) [:h1.h1.my4 (str ?title)]) + [:div.content-markdown {:dangerouslySetInnerHTML {:__html (markdown/md-to-html-string ?content)}}]] + + {:__typename "Html" :html ?html} + [:div.content-html {:dangerouslySetInnerHTML {:__html ?html}}] + + {:__typename "Paragraph" :title ?title :textAlignment ?align :text (m/cata ?html)} + [:div.content-richtext + (when (pos? (count ?title)) [:h1.h1.my4 ?title]) + [:div {:class (case ?align + "left" "" + "center" "center" + "right" "right")} + ?html]] + + ;; rich text field's structure + {:nodeType "document" :content [(m/cata !content) ...]} (m/subst [:div.inline . !content ...]) + {:nodeType "heading-1" :content [(m/cata !content) ...]} (m/subst [:h1.title-1.canela.my4 . !content ...]) + {:nodeType "heading-2" :content [(m/cata !content) ...]} (m/subst [:h2.title-2.canela.my4 . !content ...]) + {:nodeType "heading-3" :content [(m/cata !content) ...]} (m/subst [:h3.title-3.canela.my4 . !content ...]) + {:nodeType "heading-4" :content [(m/cata !content) ...]} (m/subst [:h4.title-3.canela.my3 . !content ...]) + {:nodeType "heading-5" :content [(m/cata !content) ...]} (m/subst [:h5.title-3.canela.my2 . !content ...]) + {:nodeType "heading-6" :content [(m/cata !content) ...]} (m/subst [:h6.title-3.canela.my1 . !content ...]) + {:nodeType "blockquote" :content [(m/cata !content) ...]} (m/subst [:blockquote . !content ...]) + {:nodeType "paragraph" :content [(m/cata !content) ...]} (m/subst [:p.content-2.my2 . !content ...]) + {:nodeType "embedded-asset-block" :data {:target {:sys {:id ?id}}}} (let [asset (id->entry ?id)] + [:img.block.col-12 + {:style {:height "auto"} + :src (:url asset) + :alt (str (:title asset)) + :width (:width asset) + :height (:height asset)}]) + {:nodeType "embedded-asset-inline" :data {:target {:sys {:id ?id}}}} (let [asset (id->entry ?id)] + [:img + {:src (:url asset) + :alt (str (:title asset)) + :width (:width asset) + :height (:height asset)}]) + {:nodeType "embedded-entry-block" :data {:target {:sys {:id ?id}}}} [:div.block (content-html (id->entry ?id) c)] + {:nodeType "embedded-entry-inline" :data {:target {:sys {:id ?id}}}} (content-html (id->entry ?id) c) + {:nodeType "hyperlink" :content [(m/cata !content) ...] :data {:uri ?uri}} (m/subst [:a {:href ?uri} . !content ...]) + {:nodeType "hyperlink" :content [(m/cata !content) ...]} (m/subst [:a . !content ...]) + {:nodeType "unordered-list" :content [(m/cata !content) ...]} (m/subst [:ul . !content ...]) + {:nodeType "ordered-list" :content [(m/cata !content) ...]} (m/subst [:ol . !content ...]) + {:nodeType "list-item" :content [(m/cata !content) ...]} (m/subst [:li . !content ...]) + {:nodeType "hr"} [:hr] + {:nodeType "text" :value ?text :marks []} ?text + {:nodeType "text" :value ?text :marks [{:type !marks} ...]} (let [m (set !marks)] + [:span {:class (cond-> "" + (m "bold") (str " bold") + (m "italic") (str " italic") + (m "underline") (str " underline"))} + ?text]) + {:json (m/cata ?json)} ?json + + ;; error handling + ?x (do + (println "Missing clause in `content-html` for" (pr-str ?x)) + (exception-handler (IllegalArgumentException. (format "Missing clause in `content-html` for" (pr-str ?x)))) + [:div.bg-red.white "Unrecognized content type: " (pr-str ?x)])))) + +(defn- fetch-raw [contentful path {:keys [preview? exception-handler]}] + (let [pg (-> (gql/query contentful "static_page.gql" {"preview" (boolean preview?) + "path" (str path)}) + :body + :data + :staticPageCollection + :items + first)] + (when pg + (:content pg)))) + +(defn- fetch [contentful path {:keys [preview? exception-handler]}] + (let [pg (-> (gql/query contentful "static_page.gql" {"preview" (boolean preview?) + "path" (str path)}) + :body + :data + :staticPageCollection + :items + first)] + (when pg + {:title (:title pg) + :path (:path pg) + :contents [:div.container (content-html (:content pg) exception-handler)]}))) + + +(defn- available-pages [contentful {:keys [preview?]}] + (let [pgs (-> (gql/query contentful "all_static_pages.gql" {"preview" (boolean preview?)}) + :body + :data + :staticPageCollection + :items + not-empty)] + (when pgs + (into {} + (map (fn [page] [(:path page) page])) + pgs)))) + + +(defrecord Repository [contentful routes scheduler] + Lifecycle + (start [c] + (let [options {:preview? false} + routes (atom nil)] + (when-let [interval (:static-page-fetch-interval contentful)] + (scheduler/every scheduler interval "contentful static page list" + #(when-let [pgs (available-pages contentful options)] + (reset! routes pgs)))) + (assoc c :routes routes))) + (stop [c] + (dissoc c :timer-pool))) + +(defn has-page? [repo path] + (when-let [r (:routes repo)] ;; if repo is nil, just assume nothing is available - probably dev err + (let [r @r] + (if (nil? r) + true ;; if we haven't fetched data yet, just assume it's there + (contains? r path))))) + +(defn content-for + "Returns the html string containing the page contents of the given static page" + [repo path preview?] + (let [preview? (boolean preview?) + path (str path) + path (if (string/starts-with? path "/static/") (.substring path (.length "/static")) path)] + ;; preview pages are always assumed to exist, skip cache + (when (or preview? (has-page? repo path)) + (when-let [pg (fetch (:contentful repo) path {:preview? preview? + :exception-handler (:exception-handler repo)})] + (html (normalize-element (:contents pg))))))) + +(comment + (:routes (:static-pages-repo dev-system/the-system)) + (has-page? (:static-pages-repo dev-system/the-system) "/policy/privacy") + (content-for (:static-pages-repo dev-system/the-system) "/policy/privacy" false) + + (def r (gql/query (:contentful (:static-pages-repo dev-system/the-system)) + "static_page.gql" {"preview" true + "path" "/policy/privacy"})) + (content-map r) + (def r (available-pages (:contentful dev-system/the-system) {"$preview" true})) + + (content-html {:json {:nodeType "document" + :content [{:nodeType "text" + :value "hello"}]}}) + + (content-html (-> r + :body + :data + :staticPageCollection + :items + first + :content)) + + (fetch-raw (:contentful (:static-pages-repo dev-system/the-system)) "/policy/privacy" {:preview? false + :exception-handler println}) + + (storefront.safe-hiccup/html5 + (storefront.component/normalize-element + (:contents (fetch (:contentful (:static-pages-repo dev-system/the-system)) "/policy/privacy" {:preview? false + :exception-handler println})))) + ) diff --git a/src/storefront/system/scheduler.clj b/src/storefront/system/scheduler.clj new file mode 100644 index 000000000..d7d7323aa --- /dev/null +++ b/src/storefront/system/scheduler.clj @@ -0,0 +1,46 @@ +(ns storefront.system.scheduler + (:require [overtone.at-at :as at-at] + [com.stuartsierra.component :as component])) + +(defrecord Scheduler [pool exception-handler logger] + component/Lifecycle + (start [c] + (assert exception-handler) + (assoc c :pool (at-at/mk-pool))) + (stop [c] + (when pool (at-at/stop-and-reset-pool! pool)) + (assoc c :pool nil)) + Object + (toString [s] (pr-str s))) + +(comment + (prn (:scheduler dev-system/the-system)) + (prn (at-at/show-schedule (:pool (:scheduler dev-system/the-system))))) + +(defmethod print-method Scheduler [v ^java.io.Writer w] + (.write w (format "" (if (:pool v) + (at-at/show-schedule (:pool v)) + "(No Scheduled Tasks)")))) + +(defn- safe-task [t {:keys [exception-handler logger]}] + (try + (t) + (catch Throwable t + ;; Ideally, we should never get here, but at-at halts all polls that throw exceptions silently. + ;; This simply reports it and lets the polling continue + (exception-handler t) + (when logger (logger :error t))))) + +(defn every + "Schedules task-f to be called every interval-ms in milliseconds. The next invocation is interval-ms after task-f completes." + ([scheduler interval-ms task-f] + (at-at/interspaced interval-ms (safe-task task-f scheduler) (:pool scheduler))) + ([scheduler interval-ms desc task-f] + (at-at/interspaced interval-ms (safe-task task-f scheduler) (:pool scheduler) :desc desc))) + +(defn at + "Schedules task-f to be called when the future date, unix-msec, passes" + ([scheduler unix-msec task-f] + (at-at/at unix-msec (safe-task task-f scheduler) (:pool scheduler))) + ([scheduler unix-msec desc task-f] + (at-at/at unix-msec (safe-task task-f scheduler) (:pool scheduler) :desc desc))) \ No newline at end of file