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 (
+
+
+
+
+
+
+
+
+ {examElement.metadata[0].value}
+
+
+
+ {examElement.private
+ ? "Coming Soon!"
+ : "Price: " +
+ currencyFormatter.format(
+ examElement.price.value / 100
+ )}
+
+
+
+
+
+ );
+ })}
+
+ {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
+ )}
+
+
+
+
+ Buy Now
+
+
+
+
+ >
+ );
+};
+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)
+ )}
+
+
+
+ Buy Now
+
+
+
+
+ >
+ );
+};
+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.
+
+
+ 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 = () => {
{
{
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 %}
+ Get your exam now!
+ {% 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
)