From e973f05181ac88d38d685271e54604feb970c4ab Mon Sep 17 00:00:00 2001 From: Abhigyan Ghosh <30973042+abhigyanghosh30@users.noreply.github.com> Date: Wed, 26 Jul 2023 19:01:43 +0530 Subject: [PATCH] Cred customer purchasing (#12868) * Create a shop to purchase credentials exams for customers * Modify checkout as required to accommodate changes for credentials --------- Co-authored-by: MariaPaula Trujillo --- static/js/src/advantage/credentials/app.tsx | 11 +- .../components/CredExamShop/CredExamShop.tsx | 230 ++++++++++++++++++ .../components/CredExamShop/index.tsx | 1 + .../components/CredKeyShop/CredKeyShop.tsx | 103 ++++++++ .../components/CredKeyShop/index.tsx | 1 + .../CredPurchaseConfirmation.tsx | 41 ++++ .../CredPurchaseConfirmation/index.tsx | 1 + .../components/CredShop/CredShop.tsx | 11 - .../credentials/components/CredShop/index.tsx | 1 - .../CredWebhookResponses.tsx | 15 +- .../components/BuyButton/BuyButton.tsx | 10 +- .../ConfirmAndBuy/ConfirmAndBuy.tsx | 35 ++- .../checkout/components/Summary/Summary.tsx | 14 +- .../subscribe/checkout/utils/helpers.ts | 2 + .../subscribe/checkout/utils/types.ts | 2 + static/sass/_pattern_card.scss | 4 + static/sass/_pattern_shop-cart.scss | 4 + templates/credentials/index.html | 15 +- templates/credentials/shop/index.html | 7 +- webapp/shop/cred/views.py | 17 +- 20 files changed, 491 insertions(+), 34 deletions(-) create mode 100644 static/js/src/advantage/credentials/components/CredExamShop/CredExamShop.tsx create mode 100644 static/js/src/advantage/credentials/components/CredExamShop/index.tsx create mode 100644 static/js/src/advantage/credentials/components/CredKeyShop/CredKeyShop.tsx create mode 100644 static/js/src/advantage/credentials/components/CredKeyShop/index.tsx create mode 100644 static/js/src/advantage/credentials/components/CredPurchaseConfirmation/CredPurchaseConfirmation.tsx create mode 100644 static/js/src/advantage/credentials/components/CredPurchaseConfirmation/index.tsx delete mode 100644 static/js/src/advantage/credentials/components/CredShop/CredShop.tsx delete mode 100644 static/js/src/advantage/credentials/components/CredShop/index.tsx diff --git a/static/js/src/advantage/credentials/app.tsx b/static/js/src/advantage/credentials/app.tsx index 92e7e39251d..55473b3ae49 100644 --- a/static/js/src/advantage/credentials/app.tsx +++ b/static/js/src/advantage/credentials/app.tsx @@ -6,7 +6,9 @@ import { Integrations } from "@sentry/tracing"; import { ReactQueryDevtools } from "react-query/devtools"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import CredManage from "./components/CredManage"; -import CredShop from "./components/CredShop"; +import CredKeyShop from "./components/CredKeyShop"; +import CredPurchaseConfirmation from "./components/CredPurchaseConfirmation/CredPurchaseConfirmation"; +import CredExamShop from "./components/CredExamShop/CredExamShop"; import CredWebhookResponses from "./components/CredWebhookResponses"; const oneHour = 1000 * 60 * 60; @@ -38,8 +40,13 @@ function App() { - } /> + } /> + } /> } /> + } + /> } diff --git a/static/js/src/advantage/credentials/components/CredExamShop/CredExamShop.tsx b/static/js/src/advantage/credentials/components/CredExamShop/CredExamShop.tsx new file mode 100644 index 00000000000..ea66f421b93 --- /dev/null +++ b/static/js/src/advantage/credentials/components/CredExamShop/CredExamShop.tsx @@ -0,0 +1,230 @@ +import React, { useState } from "react"; +import { + Button, + Col, + RadioInput, + Row, + Strip, +} from "@canonical/react-components"; +import classNames from "classnames"; +import { currencyFormatter } from "advantage/react/utils"; + +const CredExamShop = () => { + const ExamProducts = [ + { + id: "cue-linux-essentials", + longId: "lAK5jL8zvMjZOwaysIMQyGRAdOLgTQQH0xpezu2oYp74", + name: "CUE Linux Essentials", + period: "none", + price: { + currency: "USD", + value: 4900, + }, + productID: "cue-linux-essentials", + status: "active", + private: false, + marketplace: "canonical-cube", + metadata: [ + { + key: "description", + value: + "Prove your basic operational knowledge of Linux by demonstrating your ability to secure, operate and maintain basic system resources. Topics include user and group management, file and filesystem navigation, and logs and installation tasks related to system maintenance.", + }, + ], + }, + { + id: "cue-02-desktop", + longId: "lAMGrt4buzUR0-faJqg-Ot6dgNLn7ubIpWiyDgOrsDCg", + name: "CUE.02 Desktop QuickCert", + price: { value: 4900, currency: "USD" }, + productID: "cue-02-desktop", + canBeTrialled: false, + private: true, + marketplace: "canonical-cube", + metadata: [ + { + key: "description", + value: + "Demonstrate your knowledge of Ubuntu Desktop administrative essentials. Topics include package management, system installation, data gathering, and managing printing and displays.", + }, + ], + }, + { + id: "cue-03-server", + longId: "lAMGrt4buzUR0-faJqg-Ot6dgNLn7ubIpWiyDgOrsDCg", + name: "CUE.03 Server QuickCert", + price: { value: 4900, currency: "USD" }, + productID: "cue-03-server", + canBeTrialled: false, + private: true, + marketplace: "canonical-cube", + metadata: [ + { + key: "description", + value: + "Illustrate your knowledge of common Ubuntu Server administrative tasks and troubleshooting. Topics include job control, performance tuning, services management, and Bash scripting.", + }, + ], + }, + ]; + const [exam, setExam] = useState(0); + const handleChange = (event: React.ChangeEvent) => { + setExam(parseInt(event.target.value)); + localStorage.setItem("exam-selector", JSON.stringify(event?.target.value)); + }; + const handleSubmit = ( + event: + | React.FormEvent + | React.MouseEvent + ) => { + event.preventDefault(); + localStorage.setItem( + "shop-checkout-data", + JSON.stringify({ + product: ExamProducts[exam], + quantity: 1, + action: "purchase", + }) + ); + location.href = "/account/checkout"; + }; + return ( + <> + + +

Select an exam to purchase

+
+ + {ExamProducts.map((examElement, examIndex) => { + return ( + +
+ +
+ + ); + })} +
+ {ExamProducts.map((examElement, examIndex) => { + return ( + +
{ + setExam(examIndex); + }} + > + + +

+ {examElement.metadata[0].value} +

+
+ +
+ {examElement.private + ? "Coming Soon!" + : "Price: " + + currencyFormatter.format(examElement.price.value / 100)} +
+
+
+ + ); + })} +
+
+ + + Your Order + + + + +

+ {ExamProducts[exam].name} +

+ + +

+ {currencyFormatter.format( + (ExamProducts[exam]?.price.value ?? 0) / 100 ?? 0 + )} +

+ + + + +
+
+ + ); +}; +export default CredExamShop; diff --git a/static/js/src/advantage/credentials/components/CredExamShop/index.tsx b/static/js/src/advantage/credentials/components/CredExamShop/index.tsx new file mode 100644 index 00000000000..5a773316687 --- /dev/null +++ b/static/js/src/advantage/credentials/components/CredExamShop/index.tsx @@ -0,0 +1 @@ +export { default } from "./CredExamShop"; diff --git a/static/js/src/advantage/credentials/components/CredKeyShop/CredKeyShop.tsx b/static/js/src/advantage/credentials/components/CredKeyShop/CredKeyShop.tsx new file mode 100644 index 00000000000..93bf8356e22 --- /dev/null +++ b/static/js/src/advantage/credentials/components/CredKeyShop/CredKeyShop.tsx @@ -0,0 +1,103 @@ +import React, { useState } from "react"; +import { + Button, + Col, + Form, + Input, + Row, + Strip, +} from "@canonical/react-components"; +import { currencyFormatter } from "advantage/react/utils"; + +const CredKeyShop = () => { + const CUEExamKey = { + id: "cue-activation-key", + longId: "lAMGrt4buzUR0-faJqg-Ot6dgNLn7ubIpWiyDgOrsDCg", + name: "CUE Activation Key", + price: { value: 4900, currency: "USD" }, + productID: "cue-activation-key", + canBeTrialled: false, + private: false, + marketplace: "canonical-cube", + }; + const checkoutData = localStorage.getItem("shop-checkout-data") || "{}"; + const parsedCheckoutData = JSON.parse(checkoutData); + const initQuantity: number = parsedCheckoutData?.quantity; + const [quantity, setQuantity] = useState(initQuantity ?? 1); + const handleChange: React.ChangeEventHandler = ( + event: React.ChangeEvent + ) => { + event.preventDefault(); + setQuantity(parseInt(event.target.value)); + }; + const handleSubmit = ( + event: + | React.FormEvent + | React.MouseEvent + ) => { + event.preventDefault(); + localStorage.setItem( + "shop-checkout-data", + JSON.stringify({ + product: CUEExamKey, + quantity: quantity, + action: "purchase", + }) + ); + location.href = "/account/checkout"; + }; + return ( + <> + + +

How many exams attempts do you need?

+
+ + +
+ +
+ +
+ +

+ Each exam attempt allows you to register for one or more of the + following certifications: +

    +
  • CUE.01: Linux
  • +
  • CUE.02: Desktop
  • +
  • CUE.03: Server
  • +
+

+
+
+
+ + + Your Order + + + + {quantity} x Exam attempt key + + {currencyFormatter.format( + ((CUEExamKey?.price.value ?? 0) / 100) * (Number(quantity) ?? 0) + )} + + + + + +
+ + ); +}; +export default CredKeyShop; diff --git a/static/js/src/advantage/credentials/components/CredKeyShop/index.tsx b/static/js/src/advantage/credentials/components/CredKeyShop/index.tsx new file mode 100644 index 00000000000..3078e460992 --- /dev/null +++ b/static/js/src/advantage/credentials/components/CredKeyShop/index.tsx @@ -0,0 +1 @@ +export { default } from "./CredKeyShop"; diff --git a/static/js/src/advantage/credentials/components/CredPurchaseConfirmation/CredPurchaseConfirmation.tsx b/static/js/src/advantage/credentials/components/CredPurchaseConfirmation/CredPurchaseConfirmation.tsx new file mode 100644 index 00000000000..b52abfa95dd --- /dev/null +++ b/static/js/src/advantage/credentials/components/CredPurchaseConfirmation/CredPurchaseConfirmation.tsx @@ -0,0 +1,41 @@ +import { Button, Col, Row } from "@canonical/react-components"; +import React from "react"; +import { useSearchParams } from "react-router-dom"; + +const CredPurchaseConfirmation = () => { + const [queryParameters] = useSearchParams(); + const quantity = queryParameters.get("quantity"); + const product = queryParameters.get("productName"); + return ( +
+ +

Thank You for your order!

+

+ {quantity} x {product} +

+
+ + +

Manage Your Exams

+

+ Need to schedule or re-schedule an exam? Go to + /credentials/your-exams. +

+ + + + +
+ +

+ Consult our FAQ for further questions, or contact us at{" "} + + credentials@canonical.com + + . +

+
+
+ ); +}; +export default CredPurchaseConfirmation; diff --git a/static/js/src/advantage/credentials/components/CredPurchaseConfirmation/index.tsx b/static/js/src/advantage/credentials/components/CredPurchaseConfirmation/index.tsx new file mode 100644 index 00000000000..fb0cb3b60a5 --- /dev/null +++ b/static/js/src/advantage/credentials/components/CredPurchaseConfirmation/index.tsx @@ -0,0 +1 @@ +export { default } from "./CredPurchaseConfirmation"; diff --git a/static/js/src/advantage/credentials/components/CredShop/CredShop.tsx b/static/js/src/advantage/credentials/components/CredShop/CredShop.tsx deleted file mode 100644 index a5dfcc9b538..00000000000 --- a/static/js/src/advantage/credentials/components/CredShop/CredShop.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from "react"; -import { Link } from "react-router-dom"; -const CredShop = () => { - return ( - <> -

Shop

- manage - - ); -}; -export default CredShop; diff --git a/static/js/src/advantage/credentials/components/CredShop/index.tsx b/static/js/src/advantage/credentials/components/CredShop/index.tsx deleted file mode 100644 index 3040f99871a..00000000000 --- a/static/js/src/advantage/credentials/components/CredShop/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./CredShop"; diff --git a/static/js/src/advantage/credentials/components/CredWebhookResponses/CredWebhookResponses.tsx b/static/js/src/advantage/credentials/components/CredWebhookResponses/CredWebhookResponses.tsx index 8e8dbd1db6a..f26e6012e13 100644 --- a/static/js/src/advantage/credentials/components/CredWebhookResponses/CredWebhookResponses.tsx +++ b/static/js/src/advantage/credentials/components/CredWebhookResponses/CredWebhookResponses.tsx @@ -3,14 +3,15 @@ import { getFilteredWebhookResponses } from "advantage/credentials/api/trueabili import React, { useEffect, useState } from "react"; import { useQuery } from "react-query"; import { useSearchParams } from "react-router-dom"; +import classNames from "classnames"; type Webhook = { ability_screen_id: string; on_transition_to: string; + created_at: Date; }; type WebhookResponse = { id: string; - created_at: Date; sent_at: Date; webhook: Webhook; response_status: string; @@ -49,13 +50,13 @@ const CredWebhookResponses = () => { return { columns: [ { - content: keyitem.created_at.toString(), + content: keyitem.webhook.created_at?.toString(), }, { content: keyitem.id, }, { - content: keyitem.sent_at.toString(), + content: keyitem.sent_at?.toString(), }, { content: keyitem.webhook.ability_screen_id, @@ -78,7 +79,9 @@ const CredWebhookResponses = () => {
  1. {
  2. { request.onreadystatechange = () => { if (request.readyState === 4) { localStorage.removeItem("shop-checkout-data"); - if (!window.loginSession) { + if (product.marketplace == "canonical-cube") { + location.href = `/credentials/shop/order-thank-you?productName=${encodeURIComponent( + product.name + )}&quantity=${quantity}`; + } else if (!window.loginSession) { const email = userInfo?.customerInfo?.email || values.email || ""; - let urlBase = "/pro/subscribe"; if (product.marketplace == "blender") { urlBase = "/pro/subscribe/blender"; } - location.href = `${urlBase}/thank-you?email=${encodeURIComponent( email )}`; } else { - location.pathname = "/pro/dashboard"; + location.href = "/pro/dashboard"; } } }; diff --git a/static/js/src/advantage/subscribe/checkout/components/ConfirmAndBuy/ConfirmAndBuy.tsx b/static/js/src/advantage/subscribe/checkout/components/ConfirmAndBuy/ConfirmAndBuy.tsx index 7225787eb9a..25b6f538fd1 100644 --- a/static/js/src/advantage/subscribe/checkout/components/ConfirmAndBuy/ConfirmAndBuy.tsx +++ b/static/js/src/advantage/subscribe/checkout/components/ConfirmAndBuy/ConfirmAndBuy.tsx @@ -155,7 +155,40 @@ const getLabels = (product: Product, action: Action) => { ), }; } - + if (product.marketplace == "canonical-cube") { + return { + termsLabel: ( + <> + I agree to the{" "} + + Credentials Terms of Service + + + ), + descriptionLabel: ( + <> + I have read the{" "} + + Data Privacy Notice + + + ), + marketingLabel: ( + <> + I agree to receive information about Canonical's products and + services + + ), + }; + } return { termsLabel: ( <> diff --git a/static/js/src/advantage/subscribe/checkout/components/Summary/Summary.tsx b/static/js/src/advantage/subscribe/checkout/components/Summary/Summary.tsx index 4e478db04d6..0afe5b77f73 100644 --- a/static/js/src/advantage/subscribe/checkout/components/Summary/Summary.tsx +++ b/static/js/src/advantage/subscribe/checkout/components/Summary/Summary.tsx @@ -43,8 +43,18 @@ function Summary({ quantity, product, action, setError }: Props) { const taxAmount = (priceData?.tax ?? 0) / 100; const total = (priceData?.total ?? 0) / 100; - const units = product?.marketplace === "canonical-ua" ? "Machines" : "Users"; - const planType = action !== "offer" ? "Plan type" : "Products"; + const units = + product?.marketplace === "canonical-ua" + ? "Machines" + : product?.marketplace === "canonical-cube" + ? "Exams" + : "Users"; + const planType = + product?.marketplace === "canonical-cube" + ? "Product" + : action !== "offer" + ? "Plan type" + : "Products"; const productName = action !== "offer" ? product?.name : product?.name.replace(", ", "
    "); const discount = diff --git a/static/js/src/advantage/subscribe/checkout/utils/helpers.ts b/static/js/src/advantage/subscribe/checkout/utils/helpers.ts index 8472096ec8c..b0c9116a7a2 100644 --- a/static/js/src/advantage/subscribe/checkout/utils/helpers.ts +++ b/static/js/src/advantage/subscribe/checkout/utils/helpers.ts @@ -33,6 +33,8 @@ export function getInitialFormValues( isCardValid: !!userInfo?.customerInfo?.defaultPaymentMethod, isInfoSaved: !!userInfo?.customerInfo?.defaultPaymentMethod, totalPrice: undefined, + TermsOfService: false, + DataPrivacy: false, }; } diff --git a/static/js/src/advantage/subscribe/checkout/utils/types.ts b/static/js/src/advantage/subscribe/checkout/utils/types.ts index f2619bcd7fc..8830193d72a 100644 --- a/static/js/src/advantage/subscribe/checkout/utils/types.ts +++ b/static/js/src/advantage/subscribe/checkout/utils/types.ts @@ -45,6 +45,8 @@ export interface FormValues { VATNumber?: string; captchaValue: string | null; TermsAndConditions: boolean; + TermsOfService: boolean; + DataPrivacy: boolean; MarketingOptIn: boolean; Description: boolean; marketplace: UserSubscriptionMarketplace; diff --git a/static/sass/_pattern_card.scss b/static/sass/_pattern_card.scss index 032f9c8451e..0325acf4aba 100644 --- a/static/sass/_pattern_card.scss +++ b/static/sass/_pattern_card.scss @@ -154,6 +154,10 @@ } } + @media screen and (max-width: $breakpoint-medium) { + height: auto; + } + @media screen and (min-width: $breakpoint-large) { border: none; padding: 0; diff --git a/static/sass/_pattern_shop-cart.scss b/static/sass/_pattern_shop-cart.scss index 5a24318481c..41d612a0efb 100644 --- a/static/sass/_pattern_shop-cart.scss +++ b/static/sass/_pattern_shop-cart.scss @@ -5,6 +5,10 @@ transition: 0.5s ease-out transform; z-index: 20; + &--cue { + bottom: 0; + } + &--hidden { transform: translateY(100%); } diff --git a/templates/credentials/index.html b/templates/credentials/index.html index 6d4ade303a3..ce718e456a2 100644 --- a/templates/credentials/index.html +++ b/templates/credentials/index.html @@ -13,7 +13,9 @@

    Canonical Credentials

    Learn, excel, certify!

    Find the shortest path to your passion. Develop and certify your skills on the world's most popular Linux OS.

    -

    Sign-ups for the CUE.01: Linux beta are now closed. Please check back for future announcements and beta opportunities.

    + {% if can_purchase %} +

    + {% endif %} @@ -74,7 +76,16 @@

    Complete Canonical Ubuntu Essentials Syllabus

    CUE.01: Linux QuickCert

    Prove your basic operational knowledge of Linux by demonstrating your ability to secure, operate and maintain basic system resources. Topics include user and group management, file and filesystem navigation, and logs and installation tasks related to system maintenance.

    - Syllabus › +
    + + {% if can_purchase %} + + {% endif %} +

    diff --git a/templates/credentials/shop/index.html b/templates/credentials/shop/index.html index 24f05955e4d..dd0f8018c44 100644 --- a/templates/credentials/shop/index.html +++ b/templates/credentials/shop/index.html @@ -6,8 +6,8 @@ Ubuntu Desktop, and Ubuntu Server topics.{% endblock meta_description %} {% block content %} -
    -
    +
    +
    Loading… @@ -18,4 +18,7 @@
    + {% endblock %} diff --git a/webapp/shop/cred/views.py b/webapp/shop/cred/views.py index 5b62e8ccd8c..a879f59223d 100644 --- a/webapp/shop/cred/views.py +++ b/webapp/shop/cred/views.py @@ -42,8 +42,16 @@ @shop_decorator(area="cred", permission="user_or_guest", response="html") -def cred_home(**_): - return flask.render_template("credentials/index.html") +def cred_home(ua_contracts_api, **_): + available_products = ua_contracts_api.get_product_listings( + "canonical-cube" + ).get("productListings") + for product in available_products: + if product.get("name") == "CUE Linux Essentials": + return flask.render_template( + "credentials/index.html", can_purchase=True + ) + return flask.render_template("credentials/index.html", can_purchase=False) @shop_decorator(area="cred", permission="user_or_guest", response="html") @@ -605,12 +613,13 @@ def get_filtered_webhook_responses(trueability_api, **kwargs): ability_screen_id=ability_screen_id, page=ta_page, ) + total_count = webhook_responses["meta"]["total_count"] ta_webhook_responses = webhook_responses["webhook_responses"] ta_webhook_responses = [ ta_webhook_responses[i] for i in range( page * per_page % ta_results_per_page - per_page, - page * per_page % ta_results_per_page, + min(page * per_page % ta_results_per_page, total_count), ) ] page_metadata = {} @@ -618,7 +627,7 @@ def get_filtered_webhook_responses(trueability_api, **kwargs): page_metadata["total_pages"] = ( webhook_responses["meta"]["total_count"] // per_page ) + 1 - page_metadata["total_count"] = webhook_responses["meta"]["total_count"] + page_metadata["total_count"] = total_count page_metadata["next_page"] = ( page + 1 if page < page_metadata["total_pages"] else None )