diff --git a/cms/migrations/0031_add_faq_remove_faculty_reorder_things.py b/cms/migrations/0031_add_faq_remove_faculty_reorder_things.py
new file mode 100644
index 0000000000..874c0f3aeb
--- /dev/null
+++ b/cms/migrations/0031_add_faq_remove_faculty_reorder_things.py
@@ -0,0 +1,103 @@
+# Generated by Django 3.2.18 on 2023-07-31 14:40
+
+import wagtail.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("cms", "0030_move_instructor_short_bios_to_long"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="coursepage",
+ name="faculty_members",
+ ),
+ migrations.RemoveField(
+ model_name="programpage",
+ name="faculty_members",
+ ),
+ migrations.AddField(
+ model_name="coursepage",
+ name="faq_url",
+ field=models.URLField(
+ blank=True,
+ help_text="URL a relevant FAQ page or entry for the course/program.",
+ null=True,
+ ),
+ ),
+ migrations.AddField(
+ model_name="programpage",
+ name="faq_url",
+ field=models.URLField(
+ blank=True,
+ help_text="URL a relevant FAQ page or entry for the course/program.",
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="coursepage",
+ name="about",
+ field=wagtail.fields.RichTextField(
+ blank=True, help_text="Details about this course/program.", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="coursepage",
+ name="description",
+ field=wagtail.fields.RichTextField(
+ help_text="The description shown on the home page and product page."
+ ),
+ ),
+ migrations.AlterField(
+ model_name="coursepage",
+ name="prerequisites",
+ field=wagtail.fields.RichTextField(
+ blank=True,
+ help_text="A short description indicating prerequisites of this course/program.",
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="coursepage",
+ name="video_url",
+ field=models.URLField(
+ blank=True,
+ help_text="URL to the video to be displayed for this course/program. It can be an HLS or Youtube video URL.",
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="programpage",
+ name="about",
+ field=wagtail.fields.RichTextField(
+ blank=True, help_text="Details about this course/program.", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="programpage",
+ name="description",
+ field=wagtail.fields.RichTextField(
+ help_text="The description shown on the home page and product page."
+ ),
+ ),
+ migrations.AlterField(
+ model_name="programpage",
+ name="prerequisites",
+ field=wagtail.fields.RichTextField(
+ blank=True,
+ help_text="A short description indicating prerequisites of this course/program.",
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="programpage",
+ name="video_url",
+ field=models.URLField(
+ blank=True,
+ help_text="URL to the video to be displayed for this course/program. It can be an HLS or Youtube video URL.",
+ null=True,
+ ),
+ ),
+ ]
diff --git a/cms/models.py b/cms/models.py
index 07b3d96303..dfacf613a1 100644
--- a/cms/models.py
+++ b/cms/models.py
@@ -796,7 +796,7 @@ class Meta:
abstract = True
description = RichTextField(
- help_text="The description shown on the home page and product page. The recommended character limit is 1000 characters. Longer entries may not display nicely on the page."
+ help_text="The description shown on the home page and product page."
)
length = models.CharField(
@@ -821,15 +821,23 @@ class Meta:
prerequisites = RichTextField(
null=True,
blank=True,
- help_text="A short description indicating prerequisites of this course.",
+ help_text="A short description indicating prerequisites of this course/program.",
)
- about = RichTextField(null=True, blank=True, help_text="About this course details.")
+ about = RichTextField(
+ null=True, blank=True, help_text="Details about this course/program."
+ )
+
+ faq_url = models.URLField(
+ null=True,
+ blank=True,
+ help_text="URL a relevant FAQ page or entry for the course/program.",
+ )
video_url = models.URLField(
null=True,
blank=True,
- help_text="URL to the video to be displayed for this program/course. It can be an HLS or Youtube video URL.",
+ help_text="URL to the video to be displayed for this course/program. It can be an HLS or Youtube video URL.",
)
what_you_learn = RichTextField(
@@ -853,14 +861,6 @@ class Meta:
help_text="The title text to display in the faculty cards section of the product page.",
)
- faculty_members = StreamField(
- [("faculty_member", FacultyBlock())],
- null=True,
- blank=True,
- help_text="The faculty members to display on this page",
- use_json_field=True,
- )
-
def save(self, clean=True, user=None, log_action=False, **kwargs):
"""
Updates related courseware object title.
@@ -933,13 +933,13 @@ def is_program_page(self):
FieldPanel("effort"),
FieldPanel("price"),
FieldPanel("prerequisites"),
+ FieldPanel("faq_url"),
FieldPanel("about"),
FieldPanel("what_you_learn"),
FieldPanel("feature_image"),
- InlinePanel("linked_instructors", label="Faculty Members"),
- FieldPanel("faculty_section_title"),
- FieldPanel("faculty_members"),
FieldPanel("video_url"),
+ FieldPanel("faculty_section_title"),
+ InlinePanel("linked_instructors", label="Faculty Members"),
]
subpage_types = ["FlexiblePricingRequestForm", "CertificatePage"]
@@ -998,6 +998,18 @@ def get_context(self, request, *args, **kwargs):
"instructors": instructors,
}
+ def get_context(self, request, *args, **kwargs):
+ instructors = [
+ member.linked_instructor_page
+ for member in self.linked_instructors.order_by("order").all()
+ ]
+
+ return {
+ **super().get_context(request),
+ **get_base_context(request),
+ "instructors": instructors,
+ }
+
class CoursePage(ProductPage):
"""
diff --git a/cms/models_test.py b/cms/models_test.py
index 876a67e81c..1b1db1c024 100644
--- a/cms/models_test.py
+++ b/cms/models_test.py
@@ -170,6 +170,7 @@ def test_course_page_context(
member.linked_instructor_page
for member in course_page.linked_instructors.order_by("order").all()
],
+ "new_design": features.is_enabled("mitxonline-new-product-page"),
}
context = course_page.get_context(request=request)
diff --git a/cms/serializers.py b/cms/serializers.py
index 5a91d12d55..52209cf52e 100644
--- a/cms/serializers.py
+++ b/cms/serializers.py
@@ -21,6 +21,8 @@ class CoursePageSerializer(serializers.ModelSerializer):
current_price = serializers.SerializerMethodField()
instructors = serializers.SerializerMethodField()
live = serializers.SerializerMethodField()
+ effort = serializers.SerializerMethodField()
+ length = serializers.SerializerMethodField()
def get_feature_image_src(self, instance):
"""Serializes the source of the feature_image"""
@@ -100,15 +102,18 @@ def get_current_price(self, instance):
return relevant_product.price if relevant_product else None
def get_instructors(self, instance):
- members = [member.value for member in instance.faculty_members]
+ members = [
+ member.linked_instructor_page
+ for member in instance.linked_instructors.all()
+ ]
returnable_members = []
for member in members:
returnable_members.append(
{
- "name": member["name"],
+ "name": member.instructor_name,
"description": bleach.clean(
- member["description"].source, tags=[], strip=True
+ member.instructor_bio_short, tags=[], strip=True
),
}
)
@@ -118,6 +123,20 @@ def get_instructors(self, instance):
def get_live(self, instance):
return instance.live
+ def get_effort(self, instance):
+ return (
+ bleach.clean(instance.effort, tags=[], strip=True)
+ if instance.effort
+ else None
+ )
+
+ def get_length(self, instance):
+ return (
+ bleach.clean(instance.length, tags=[], strip=True)
+ if instance.length
+ else None
+ )
+
class Meta:
model = models.CoursePage
fields = [
@@ -128,6 +147,8 @@ class Meta:
"current_price",
"instructors",
"live",
+ "length",
+ "effort",
]
@@ -154,3 +175,36 @@ class Meta:
"feature_image_src",
"page_url",
]
+
+
+class InstructorPageSerializer(serializers.ModelSerializer):
+ """Instructor page model serializer"""
+
+ feature_image_src = serializers.SerializerMethodField()
+
+ def get_feature_image_src(self, instance):
+ """Serializes the source of the feature_image"""
+ feature_img_src = None
+ if hasattr(instance, "feature_image"):
+ feature_img_src = get_wagtail_img_src(instance.feature_image)
+
+ return feature_img_src or static(DEFAULT_COURSE_IMG_PATH)
+
+ class Meta:
+ model = models.InstructorPage
+ fields = [
+ "id",
+ "instructor_name",
+ "instructor_title",
+ "instructor_bio_short",
+ "instructor_bio_long",
+ "feature_image_src",
+ ]
+ read_only_fields = [
+ "id",
+ "instructor_name",
+ "instructor_title",
+ "instructor_bio_short",
+ "instructor_bio_long",
+ "feature_image_src",
+ ]
diff --git a/cms/serializers_test.py b/cms/serializers_test.py
index 98d85f4db8..acd8d2cee2 100644
--- a/cms/serializers_test.py
+++ b/cms/serializers_test.py
@@ -55,6 +55,8 @@ def test_serialize_course_page(
"current_price": None,
"description": bleach.clean(course_page.description, tags=[], strip=True),
"live": True,
+ "length": course_page.length,
+ "effort": course_page.effort,
},
)
patched_get_wagtail_src.assert_called_once_with(course_page.feature_image)
@@ -96,6 +98,8 @@ def test_serialize_course_page_with_flex_price_with_program_fk_and_parent(
"current_price": None,
"description": bleach.clean(course_page.description, tags=[], strip=True),
"live": True,
+ "length": course_page.length,
+ "effort": course_page.effort,
},
)
@@ -136,6 +140,8 @@ def test_serialize_course_page_with_flex_price_with_program_fk_no_parent(
"current_price": None,
"description": bleach.clean(course_page.description, tags=[], strip=True),
"live": True,
+ "length": course_page.length,
+ "effort": course_page.effort,
},
)
@@ -176,6 +182,8 @@ def test_serialize_course_page_with_flex_price_form_as_program_child(
"current_price": None,
"description": bleach.clean(course_page.description, tags=[], strip=True),
"live": True,
+ "length": course_page.length,
+ "effort": course_page.effort,
},
)
@@ -213,5 +221,7 @@ def test_serialize_course_page_with_flex_price_form_as_child_no_program(
"current_price": None,
"description": bleach.clean(course_page.description, tags=[], strip=True),
"live": True,
+ "length": course_page.length,
+ "effort": course_page.effort,
},
)
diff --git a/cms/templates/product_page.html b/cms/templates/product_page.html
index 25f3e25606..3e73ef4e94 100644
--- a/cms/templates/product_page.html
+++ b/cms/templates/product_page.html
@@ -2,10 +2,150 @@
{% load static wagtail_img_src feature_img_src %}
{% load wagtailcore_tags wagtailembeds_tags %}
+{% load expand %}
{% block title %}{{ page.title }} | {{ site_name }}{% endblock %}
{% block content %}
+{% if new_design %}
+
+
+
+
+
+
{{ page.title }}
+ {# Description field contents are already rendered wrapped in a
tag #}
+
+
+ {{ page.description | richtext }}
+
+
+
+
+
+
+ {% if page.about %}
+ {{ page.about | richtext | expand }}
+ {% endif %}
+
+ {% if page.what_you_learn %}
+ What you'll learn
+ {{ page.what_you_learn |richtext }}
+ {% endif %}
+
+ {% if instructors or page.faculty_members %}
+
+
+
{{ page.faculty_section_title }}
+
+ {% if instructors %}
+ {% for member in instructors %}
+
+
+
+
+
+ {{ member.instructor_name }}
+
+ {% if member.instructor_title %}
{{ member.instructor_title }} {% endif %}
+
{{ member.instructor_bio_short|safe }}
+
+
+
+ {% endfor %}
+ {% endif %}
+
+
+
+ {% endif %}
+
+
+
+
Who can take this course?
+
+
Because of U.S. Office of Foreign Assets Control (OFAC) restrictions and other U.S. federal regulations, learners residing in one or more of the following countries or regions will not be able to register for this course: Iran, Cuba, Syria, North Korea and the Crimea, Donetsk People's Republic and Luhansk People's Republic regions of Ukraine.
+
+
+
+
+
+
+
+
+
+ {% if page.course %}
+
+
+ {% endif %}
+
+
+
+ {% if instructors %}
+ {% for member in instructors %}
+
+
+
+
+
+
+
+
+
+
+
{{member.instructor_name}}
+ {% if member.instructor_title %}
{{member.instructor_title}} {% endif %}
+
+
{{member.instructor_bio_short|safe}}
+
+
+
+
+ {% if member.instructor_bio_long %}
+ {{ member.instructor_bio_long|safe }}
+ {% endif %}
+ {% if not member.instructor_bio_long %}
+ {{ member.instructor_bio_short|safe}}
+ {% endif %}
+
+
+
+
+
+
+ {% endfor %}
+ {% endif %}
+
+
+
+
+{% else %}
@@ -153,7 +293,7 @@
{{ page.faculty_section_title }}
{{ member.instructor_name }}
{% if member.instructor_title %}
{{ member.instructor_title }} {% endif %}
-
{{ member.instructor_bio_long|safe }}
+
{{ member.instructor_bio_short|safe }}
@@ -185,4 +325,5 @@ {{ member.value.name }}
+{% endif %}
{% endblock %}
diff --git a/cms/templatetags/expand.py b/cms/templatetags/expand.py
new file mode 100644
index 0000000000..aeefeb2bd7
--- /dev/null
+++ b/cms/templatetags/expand.py
@@ -0,0 +1,48 @@
+"""
+Adds an expandable Read More section to a text block.
+
+The following rules are followed to determine where the content is split:
+- If the text is HTML and contains a special tag (class="expand_here"), then
+ that will be where the split occurs.
+- If the text is HTML, the content will be split in two after the first or
+
tag.
+- If the text is not HTML, then split after the first two concurrent newlines.
+
+This is intended for use with a Wagtail RichText field.
+"""
+
+import uuid
+
+from bs4 import BeautifulSoup
+from django import template
+
+register = template.Library()
+
+
+@register.filter(name="expand", is_safe=True, needs_autoescape=False)
+def expand(text):
+ soup = BeautifulSoup(text, "html.parser")
+
+ container_uuid = str(uuid.uuid4())
+ pre = post = output = None
+
+ expand_here = soup.find_all(["p", "div"], attrs={"class": "expand-here"}, limit=1)
+
+ if len(expand_here) > 0:
+ pre = "".join([str(sib) for sib in expand_here.find_previous_siblings()])
+ post = "".join([str(sib) for sib in expand_here.find_next_siblings()])
+
+ output = f'{pre}
Show More
{str(expand_here[0])}{post}
'
+ elif len(soup.find_all(["p", "div"])) > 0:
+ expand_here = soup.find_all(["p", "div"], limit=2)
+
+ pre = str(expand_here[0])
+ post = "".join([str(sib) for sib in expand_here[1].find_next_siblings()])
+
+ output = f'{pre}
Show More
{str(expand_here[1])}{post}
'
+ else:
+ (pre, post) = text.split("\n\n", maxsplit=1)
+
+ output = f'{pre}
Show More
{post}
'
+
+ return output
diff --git a/cms/urls.py b/cms/urls.py
index 3f74a9f795..374ecff750 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -12,13 +12,11 @@
The pattern(s) defined here serve the same Wagtail view that the library-defined pattern serves.
"""
from django.conf.urls import url
-
from wagtail import views
from wagtail.coreutils import WAGTAIL_APPEND_SLASH
from cms.constants import COURSE_INDEX_SLUG, PROGRAM_INDEX_SLUG
-
detail_path_char_pattern = r"\w\-\.+:"
if WAGTAIL_APPEND_SLASH:
diff --git a/cms/views.py b/cms/views.py
new file mode 100644
index 0000000000..483fa1c5ef
--- /dev/null
+++ b/cms/views.py
@@ -0,0 +1,14 @@
+import logging
+
+from rest_framework.decorators import api_view
+from rest_framework.response import Response
+
+from cms.models import InstructorPage
+from cms.serializers import InstructorPageSerializer
+
+
+@api_view(["GET"])
+def instructor_page(request, id, format=None):
+ page = InstructorPage.objects.get(pk=id)
+
+ return Response(InstructorPageSerializer(page).data)
diff --git a/courses/serializers.py b/courses/serializers.py
index a1ed2e5b91..4d273da80e 100644
--- a/courses/serializers.py
+++ b/courses/serializers.py
@@ -100,6 +100,7 @@ class Meta:
"courseware_id",
"upgrade_deadline",
"is_upgradable",
+ "is_self_paced",
"run_tag",
"id",
]
@@ -164,6 +165,7 @@ class CourseSerializer(BaseCourseSerializer):
next_run_id = serializers.SerializerMethodField()
topics = serializers.SerializerMethodField()
page = serializers.SerializerMethodField()
+ programs = serializers.SerializerMethodField()
def get_next_run_id(self, instance):
"""Get next run id"""
@@ -204,6 +206,14 @@ def get_page(self, instance):
else None
)
+ def get_programs(self, instance):
+ if self.context.get("all_runs", False):
+ from courses.serializers import BaseProgramSerializer
+
+ return BaseProgramSerializer(instance.programs, many=True).data
+
+ return None
+
class Meta:
model = models.Course
fields = [
@@ -214,6 +224,7 @@ class Meta:
"next_run_id",
"topics",
"page",
+ "programs",
]
diff --git a/courses/serializers_test.py b/courses/serializers_test.py
index 092ff920ca..3878ad9dc9 100644
--- a/courses/serializers_test.py
+++ b/courses/serializers_test.py
@@ -39,7 +39,7 @@
from flexiblepricing.constants import FlexiblePriceStatus
from flexiblepricing.factories import FlexiblePriceFactory
from main.test_utils import assert_drf_json_equal, drf_datetime
-from openedx.constants import EDX_ENROLLMENT_VERIFIED_MODE, EDX_ENROLLMENT_AUDIT_MODE
+from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE
pytestmark = [pytest.mark.django_db]
@@ -187,6 +187,9 @@ def test_serialize_course(mock_context, is_anonymous, all_runs):
"next_run_id": course.first_unexpired_run.id,
"topics": [{"name": topic}],
"page": None,
+ "programs": ProgramSerializer(course.programs, many=True).data
+ if all_runs
+ else None,
},
)
@@ -231,6 +234,8 @@ def test_serialize_course_with_page_fields(
"current_price": None,
"description": bleach.clean(course_page.description, tags=[], strip=True),
"live": True,
+ "effort": course_page.effort,
+ "length": course_page.length,
},
)
patched_get_wagtail_src.assert_called_once_with(course_page.feature_image)
@@ -260,6 +265,7 @@ def test_serialize_course_run():
"products": [],
"page": None,
"approved_flexible_price_exists": False,
+ "is_self_paced": course_run.is_self_paced,
},
)
diff --git a/courses/views/v1/__init__.py b/courses/views/v1/__init__.py
index 26c85c9c15..eb15194ea9 100644
--- a/courses/views/v1/__init__.py
+++ b/courses/views/v1/__init__.py
@@ -83,7 +83,20 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = []
serializer_class = CourseSerializer
- queryset = Course.objects.filter(live=True)
+
+ def get_queryset(self):
+ readable_id = self.request.query_params.get("readable_id", None)
+ if readable_id:
+ return Course.objects.filter(live=True, readable_id=readable_id)
+
+ return Course.objects.filter(live=True)
+
+ def get_serializer_context(self):
+ added_context = {}
+ if self.request.query_params.get("readable_id", None):
+ added_context["all_runs"] = True
+
+ return {**super().get_serializer_context(), **added_context}
class CourseRunViewSet(viewsets.ReadOnlyModelViewSet):
diff --git a/frontend/public/scss/meta-product-page.scss b/frontend/public/scss/meta-product-page.scss
new file mode 100644
index 0000000000..6e596eefab
--- /dev/null
+++ b/frontend/public/scss/meta-product-page.scss
@@ -0,0 +1,8 @@
+@import "layout";
+@import "product-page/user-menu";
+@import "product-page/product-details";
+@import "product-page/product-faculty-members";
+
+body {
+ background: white;
+}
\ No newline at end of file
diff --git a/frontend/public/scss/product-details.scss b/frontend/public/scss/product-details.scss
index f0b9012706..fb1d4445e6 100644
--- a/frontend/public/scss/product-details.scss
+++ b/frontend/public/scss/product-details.scss
@@ -458,7 +458,7 @@
font-weight: bold;
letter-spacing: 0;
line-height: 20px;
- text-align: right;
+ text-align: center;
text-decoration: underline;
margin-bottom: 0;
diff --git a/frontend/public/scss/product-page/product-details.scss b/frontend/public/scss/product-page/product-details.scss
new file mode 100644
index 0000000000..4b4807c216
--- /dev/null
+++ b/frontend/public/scss/product-page/product-details.scss
@@ -0,0 +1,758 @@
+// sass-lint:disable mixins-before-declarations
+
+.product-page {
+ position: relative;
+ margin-top: 1.5rem;
+ background-color: white;
+ font-family: "Avenir Next", sans-serif;
+
+ section.course-description,
+ #tab-bar,
+ section.about-richtext-container {
+ font-family: "Roboto", sans-serif;
+
+ h2 {
+ font-family: "Avenir Next", "Roboto", sans-serif;
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ h2 {
+ font-size: 20px;
+ line-height: 20px;
+ }
+
+ h3 {
+ font-size: 20px;
+ line-height: 26px;
+ }
+ }
+
+ .container {
+ max-width: 1260px;
+ padding: 0 15px;
+ box-sizing: border-box;
+ margin: 0 auto;
+ }
+
+ .btn-primary.highlight {
+ @include media-breakpoint-down(sm) {
+ display: block;
+ width: 100%;
+ }
+ }
+
+ section.course-description {
+ line-height: 46px;
+ font-size: 20px;
+ color: #646669;
+ }
+
+ #tab-bar {
+ background-color: #edf2f4;
+ padding: 15px 19px;
+ margin: 2em 0;
+
+ ul li.nav-item {
+ padding: 0;
+ padding-right: 40px;
+ color: #35607a;
+ font-weight: normal;
+
+ a {
+ color: #35607a;
+ text-decoration: none;
+ }
+ }
+ }
+
+ section.about-richtext-container {
+ margin: 2.5rem 0;
+
+ div.expand_here_body {
+ max-height: 0px;
+ overflow-y: hidden;
+ transition: all 0.4s ease;
+ }
+
+ div.expand_here_body.open {
+ max-height: 400rem;
+ }
+ }
+
+ section.about-this-class {
+ color: #000;
+ /* TODO: this requires a different font and colors */
+ h2, h3, h4, h5, h6 {
+ font-size: smaller;
+ }
+ }
+
+ .hero-block {
+ font-size: 20px;
+ line-height: 2rem;
+ color: $black;
+ padding: 20px 0;
+
+ @include media-breakpoint-up(md) {
+ font-size: 24px;
+ line-height: 36px;
+ }
+
+ @include media-breakpoint-down(md) {
+ padding: 0;
+ }
+
+ @include media-breakpoint-up(lg) {
+ padding: 38px 0 5px;
+ }
+
+ .content-row {
+ @include media-breakpoint-up(lg) {
+ display: flex;
+ flex-flow: row wrap;
+ margin: 0 -15px;
+ }
+
+ @include media-breakpoint-down(md) {
+ display: flex;
+ flex-flow: column-reverse;
+ }
+
+ .content-col {
+ padding: 0 0 26px;
+
+ @include media-breakpoint-up(lg) {
+ width: calc(100% - 497px);
+ padding: 0 15px;
+ box-sizing: border-box;
+ }
+
+ &:only-child {
+ width: 100%;
+ }
+
+ &.hero-media {
+ padding-bottom: 0;
+
+ @include media-breakpoint-down(md) {
+ margin: 0px -15px;
+ }
+
+ @include media-breakpoint-up(lg) {
+ width: 497px;
+ padding: 6px 15px;
+ flex-shrink: 0;
+ }
+
+ img, .video-js {
+ width: 100%;
+ height: 217px;
+ object-fit: cover;
+
+ @include media-breakpoint-up(md) {
+ height: 263px;
+ }
+ }
+ }
+
+ .text {
+ padding-top: 7px !important;
+
+ @include media-breakpoint-down(md) {
+ padding-top: 25px !important;
+ }
+ }
+ }
+ }
+
+ .video-js {
+ .vjs-big-play-button {
+ background: black;
+ opacity: 0.8;
+ width: 64px;
+ height: 64px;
+ border: none;
+ border-radius: 50%;
+
+ &:hover {
+ opacity: 0.6;
+ }
+ }
+
+ .vjs-poster {
+ height: fit-content;
+ }
+ }
+
+ .video-js .vjs-big-play-button .vjs-icon-placeholder:before {
+ top: 0.3em;
+ }
+
+ .vjs-youtube {
+
+ .vjs-big-play-button {
+ display: none;
+ }
+ }
+
+ .video-holder {
+ background: $sirocco;
+ margin-top: 10px;
+ min-height: 200px;
+
+ img {
+ display: block;
+ width: 100%;
+ }
+ }
+
+ .text-info {
+ @include media-breakpoint-up(lg) {
+ max-width: 625px;
+ }
+ }
+
+ p {
+ margin: 0 0 30px 0;
+ }
+ }
+
+ #instructors {
+ background-color: #eff3f6;
+ margin: 1.5rem 0;
+ padding: 30px 20px;
+ border: 1px solid #dce1e9;
+ }
+
+ .ofac-message {
+ border: 1px solid #e4e9ef;
+ padding: 2rem;
+ clear: both;
+ }
+
+ #product-info-box {
+ padding-bottom: 25px;
+ font-size: 16px;
+ line-height: 24px;
+ color: $black;
+ max-width: 380px;
+ height: auto;
+
+ strong {
+ font-weight: 600;
+ }
+
+ @include media-breakpoint-up(lg) {
+ padding: 30px 0;
+ }
+
+ border-radius: 5px;
+
+ .hero-media {
+ margin: 7px 15px;
+
+ img {
+ border-radius: 5px;
+ max-width: 350px;
+ }
+ }
+
+ .stats-row {
+ display: flex;
+ flex-flow: column;
+ align-items: top;
+
+ margin-top: 10px;
+ width: 350px;
+
+ @include media-breakpoint-up(xl) {
+ margin: 0 -18px;
+ }
+
+ .font-weight-bold {
+ font-weight: 600;
+ }
+
+ .btn-enrollment-button {
+ height: 50px;
+ width: 100%;
+ padding: 0!important;
+ }
+
+ .enrollment-info-box, .program-info-box {
+ margin: 10px 0;
+ padding: 10px 0;
+ }
+
+ .enrollment-info-box {
+ border: 1px solid #d1d1d1;
+ border-left: none;
+ border-right: none;
+
+ .row {
+ margin: 20px 0;
+ }
+
+ .enrollment-info-icon {
+ width: 20px;
+ margin-right: 12px;
+ }
+
+ .enrollment-info-text {
+ width: auto;
+ flex-grow: 1;
+
+ .enrollment-effort {
+ font-size: smaller;
+ }
+
+ .badge-pacing {
+ background-color: $black;
+ text-transform: uppercase;
+ margin-left: .5rem;
+ padding: 3px;
+ font-weight: normal;
+ }
+ }
+ }
+
+ .program-info-box {
+ ul {
+ margin-top: 1em;
+ }
+ }
+
+ .stat-col {
+ margin: 0 8px 16px;
+ background: $white;
+ border-radius: 3px;
+ overflow: hidden;
+ text-align: center;
+ display: flex;
+ flex-flow: column;
+ flex-grow: 1;
+ width: calc(50% - 16px);
+ box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14);
+
+ @include media-breakpoint-up(lg) {
+ width: auto;
+ }
+
+ @include media-breakpoint-up(xl) {
+ margin: 0 18px;
+ width: 180px;
+ flex-grow: inherit;
+ }
+
+ &.small {
+ font-size: 14px;
+ @include media-breakpoint-up(xl) {
+ width: 140px;
+ }
+ }
+
+ &.large {
+ flex-grow: 1;
+ text-align: left;
+ }
+ }
+
+ .text {
+ padding: 14px 20px;
+ width: 100%;
+ flex-grow: 1;
+ display: inline-block;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ font-size: 13px;
+
+ p {
+ margin: 0;
+ }
+
+ a:link {
+ color: $link-blue;
+ text-decoration: underline;
+ }
+ }
+
+ .more-dates {
+ padding-bottom: 6px;
+ width: 100%;
+ font-size: 12px;
+
+ .dates-tooltip {
+ font-weight: bolder;
+ color: $link-blue !important;
+ text-decoration: underline;
+ background-color: transparent;
+ border: none;
+ }
+ }
+
+ .date-links-text {
+ padding-bottom: 7px !important;
+ }
+
+ .title {
+ background-color: $sirocco;
+ font-weight: 500;
+ color: $tile-background;
+ text-align: center;
+ padding: 9px 8px;
+ width: 100%;
+ box-sizing: border-box;
+ height: 34px;
+ text-transform: uppercase;
+ }
+ }
+
+ a {
+ color: $black;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+ }
+
+ .text-block {
+ color: $black;
+ padding: 0;
+
+ @include media-breakpoint-up(lg) {
+ padding: 25px 0 10px;
+ }
+
+ p {
+ margin: 0 0 25px;
+ line-height: 28px;
+ }
+
+ .text-holder {
+ max-width: 1020px;
+
+ a {
+ font-weight: 700;
+ color: $black;
+ }
+
+ .content-data {
+ @include media-breakpoint-up(lg) {
+ padding-bottom: 20px;
+ }
+ }
+
+ }
+
+ .more {
+ font-weight: 700;
+ color: $black;
+ }
+
+ ul:not(class) {
+ margin: 0 0 6px;
+ padding: 0;
+ list-style: none;
+
+ li {
+ position: relative;
+ padding: 0 0 0 18px;
+
+ &:before {
+ position: absolute;
+ left: 0;
+ top: 11px;
+ content: '';
+ background: $black;
+ width: 6px;
+ height: 6px;
+ border-radius: 100%;
+ }
+ }
+ }
+
+ #upsellCard {
+ width: 497px;
+ padding: 0 24px 24px 24px;
+ flex-shrink: 0;
+ float: right;
+
+ @include media-breakpoint-down(lg) {
+ width: 100%;
+ padding: 0 12px 12px 12px;
+ }
+
+ div.card {
+ padding: 24px;
+ }
+
+ p {
+ line-height: 24px;
+ }
+
+ div.upsell-header {
+ margin-left: 0;
+ margin-right: 0;
+
+ span.badge {
+ font-weight: normal;
+ font-size: .85rem;
+ margin-bottom: .25rem !important;
+ padding: 0.35rem !important;
+ }
+
+ span.bg-danger {
+ background-color: #fae8e8 !important;
+ color: #b43e3e;
+ }
+ }
+ form {
+ margin-bottom: 20px;
+ }
+ }
+ }
+
+ .about-text {
+ padding-bottom: 37px;
+
+ @include media-breakpoint-down(sm) {
+ padding-bottom: 20px;
+ }
+ }
+}
+
+.upgrade-enrollment-modal {
+ max-width: 750px;
+ font-family: "Avenir Next", "Roboto", sans-serif;
+
+ .modal-content {
+ padding: 5px 14px;
+
+ .required {
+ color: $brand-button-bg;
+ }
+ }
+
+ .modal-header {
+ font-weight: 600;
+ font-size: large;
+ border-bottom: none;
+ padding-bottom: 0;
+
+ font-family: "Poppins", "Avenir Next", "Roboto", sans-serif;
+
+ h5 {
+ font-size: 30px;
+ }
+
+ span {
+ font-size: 45px;
+ font-weight: 200;
+ }
+ }
+
+ a {
+ text-decoration: underline;
+ }
+
+ .modal-subheader {
+ font-weight: 600;
+ font-size: 20px;
+ margin-bottom: 20px;
+ padding-left: 15px;
+ padding-right: 15px;
+ }
+
+ .cancel-link, .faq-link {
+ text-align: center !important;
+ margin: 20px 0;
+ }
+
+ .faq-link a {
+ color: black;
+ }
+
+ .faq-link a:visited {
+ color: rgba(0, 0, 0, 0.14);
+ }
+
+ .upgrade-icon {
+ background: url("https://via.placeholder.com/150 ") left no-repeat;
+ height: 70px;
+ }
+
+ .enroll-now-free {
+ text-decoration: underline;
+ box-shadow: none;
+ }
+ form {
+ margin-bottom: 20px;
+
+ .btn-upgrade {
+ width: 100%;
+ background-color: #A41E34;
+ color: white;
+ text-align: left;
+ background-image: url('/static/images/arrow-line-right.png');
+ background-position: 90% 50%;
+ background-repeat: no-repeat;
+ padding: 11px 22px;
+ }
+ }
+
+ .acheiving-text {
+ font-weight: 600;
+ color: #A41E34;
+ margin-bottom: 1.5rem;
+ }
+
+ ul {
+ padding: 0;
+ }
+
+ li {
+ list-style: none;
+ min-height: 3.5rem;
+ background-image: url('/static/images/upsell-check.png');
+ background-repeat: no-repeat;
+ background-position: 0 0;
+ padding-left: 33px;
+ line-height: 24px;
+ }
+}
+
+div.certificate-pricing-row {
+ border: 1px solid #DFE5EC;
+ border-left: none;
+ border-right: none;
+ padding: 25px 0 15px 0;
+}
+
+div.certificate-pricing{
+ background-image: url('/static/images/products/certificate.png');
+ background-repeat: no-repeat;
+ background-position: 10px 5px;
+
+ padding-left: 43px;
+}
+
+.financial-assistance-link {
+ height: 22px;
+ letter-spacing: 0;
+ line-height: 20px;
+ text-decoration: underline;
+ margin-bottom: 0;
+
+ &:hover{
+ text-decoration: none !important;
+ }
+
+ a {
+ color: black;
+
+ &:hover{
+ color: $brand-darker-bg;
+ text-decoration: none !important;
+ }
+ }
+}
+
+.popover-header {
+ text-align: center;
+ background: $white;
+}
+
+.date-link {
+ color: $link-blue !important;
+ text-decoration: underline;
+ background-color: transparent;
+ border: none;
+}
+
+.date-link-disabled {
+ padding: 5px 5px 0;
+ margin: 0;
+}
+
+.instructor-modal {
+ .modal-dialog {
+ width: 770px;
+ max-width: 770px;
+ border-radius: 5px;
+
+ .modal-header {
+ background-color: #03152d;
+ background-image: url('/static/images/instructor-modal-head.jpg');
+ background-position: 0 60%;
+ background-size: cover;
+ height: 130px;
+ align-items: start;
+ padding: 20px;
+
+ h5 {
+ margin-left: -9999px;
+ }
+
+ button.close {
+ border: 0;
+ border-radius: 100%;
+ width: 30px;
+ height: 30px;
+ }
+ }
+
+ .modal-body {
+ padding-left: 50px;
+ padding-right: 50px;
+
+ div.col-instructor-photo {
+ margin-top: -50px;
+ width: 165px !important;
+ max-width: 165px;
+
+ img.img-thumbnail {
+ width: 130px;
+ height: 145px;
+ padding: 7px;
+ margin: 7px;
+ object-fit: cover;
+ object-position: 50% 0;
+
+ box-shadow: 0px 2px #e1e1e1;
+ }
+ }
+
+ div.col-instructor-title {
+ width: auto;
+ margin-left: 10px;
+
+ h2 {
+ margin-bottom: 0px;
+ line-height: 30px;
+ font-size: 25px;
+ font-weight: 700;
+ }
+ h3, p {
+ font-size: 18px;
+ line-height: 21.6px;
+ font-weight: 400;
+ margin-bottom: 0px;
+ }
+ h3 {
+ font-weight: 600;
+ }
+ p {
+ margin-top: 5px;
+ }
+ }
+
+ .row-instructor-body {
+ padding: 10px 10px;
+ }
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/frontend/public/scss/product-page/product-faculty-members.scss b/frontend/public/scss/product-page/product-faculty-members.scss
new file mode 100644
index 0000000000..b7be1fa92d
--- /dev/null
+++ b/frontend/public/scss/product-page/product-faculty-members.scss
@@ -0,0 +1,72 @@
+// sass-lint:disable mixins-before-declarations
+.faculty-section {
+ margin: 0 0 35px;
+
+ h2 {
+ margin: 0 0 35px;
+
+ @include media-breakpoint-down(sm) {
+ margin: 0 0 27px;
+ }
+ }
+}
+
+.faculty-members {
+ li {
+ @include media-breakpoint-down(md) {
+ width: calc(100% - 44px);
+ }
+
+ width: calc(50% - 44px);
+ margin: 0 22px 24px;
+
+ .member-card {
+ padding: 24px;
+ overflow: hidden;
+ border-radius: 4px !important;
+ box-shadow: 0 1px 0 0 rgb(0 0 0 / 12%);
+
+ .member-info {
+ overflow: hidden;
+ margin-top: 7px;
+
+ @include media-breakpoint-down(md) {
+ margin-top: 20px;
+ }
+
+ p {
+ font-size: 14px;
+ }
+
+ .description {
+ p {
+ margin: 0;
+ color: $label-subtitle;
+ }
+ }
+ }
+
+ &:hover {
+ h3 {
+ color: $brand-button-bg;
+ }
+ }
+ }
+
+ img {
+ float: left;
+ height: 110px;
+ width: 110px;
+ border-radius: 5px;
+ object-fit: cover;
+ object-position: 50% 0;
+ margin-right: 24px;
+
+ @include media-breakpoint-down(sm) {
+ display: block;
+ float: none;
+ margin: 0 auto 10px;
+ }
+ }
+ }
+}
diff --git a/frontend/public/scss/product-page/user-menu.scss b/frontend/public/scss/product-page/user-menu.scss
new file mode 100644
index 0000000000..9f65bc3d88
--- /dev/null
+++ b/frontend/public/scss/product-page/user-menu.scss
@@ -0,0 +1,88 @@
+// sass-lint:disable mixins-before-declarations
+.profile-image {
+ border-radius: 100%;
+ background-color: $cornflower-blue;
+ color: $white;
+ width: 34px;
+ height: 34px;
+ overflow: hidden;
+}
+
+.collapsed.dropdown-toggle::after {
+ border-top: 0.3em solid transparent;
+ border-left: 0.3em solid;
+ border-bottom: 0.3em solid transparent;
+ border-right: 0;
+ vertical-align: unset;
+}
+
+.user-menu .dropdown-menu {
+ margin-top: 27px;
+ padding: 0;
+ display: none;
+
+ &.show {
+ display: block;
+ z-index: 2000;
+ }
+}
+
+.user-menu.dropdown {
+ @include media-breakpoint-down(sm) {
+ display: none;
+ }
+ .dropdown-toggle.user-menu-button {
+ &.show {
+ &:after {
+ border-color: $primary;
+ transform: rotate(45deg);
+ margin-top: 1px;
+ }
+ }
+ }
+
+ .dropdown-toggle {
+ max-width: 40ch;
+ padding: 0px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ position: relative;
+ padding-right: 20px;
+
+ &:after {
+ border: 2px;
+ border-top: 2px solid black;
+ border-left: 2px solid black;
+ width: 10px;
+ height: 10px;
+ transform: rotate(225deg);
+ margin-left: 10px;
+ margin-top: -5px;
+ display: inline-block;
+ vertical-align: middle;
+ position: absolute;
+ right: 2px;
+ top: 10px;
+ }
+
+ &:hover {
+ cursor: pointer;
+ }
+ }
+}
+
+.icon-dashboard {
+ background: url("#{$static-path}/images/icon-dashboard.png") left no-repeat;
+ height: 25px;
+ width: 25px;
+}
+
+.user-menu-button {
+
+ @include media-breakpoint-down(sm) {
+ display: none;
+ }
+
+ border-color: transparent;
+ background-color: inherit;
+}
diff --git a/frontend/public/src/components/CourseInfoBox.js b/frontend/public/src/components/CourseInfoBox.js
new file mode 100644
index 0000000000..bc2be71fe6
--- /dev/null
+++ b/frontend/public/src/components/CourseInfoBox.js
@@ -0,0 +1,143 @@
+import React from "react"
+import { formatPrettyDate, emptyOrNil } from "../lib/util"
+import moment from "moment-timezone"
+
+import type { BaseCourseRun } from "../flow/courseTypes"
+
+type CourseInfoBoxProps = {
+ courses: Array
+}
+
+export default class CourseInfoBox extends React.PureComponent {
+ render() {
+ const { courses } = this.props
+
+ if (!courses || courses.length < 1) {
+ return null
+ }
+
+ const course = courses[0]
+
+ const run = course.next_run_id
+ ? course.courseruns.find(elem => elem.id === course.next_run_id)
+ : course.courseruns[0]
+
+ const product = run && run.products.length > 0 && run.products[0]
+
+ const startDate =
+ run && !emptyOrNil(run.start_date)
+ ? moment(new Date(run.start_date))
+ : null
+
+ return (
+ <>
+
+
+
+
+
+
+ {startDate ? formatPrettyDate(startDate) : "Start Anytime"}
+
+
+ {course && course.page ? (
+
+
+
+
+
+ {course.page.length}
+ {run && run.is_self_paced ? (
+
SELF-PACED
+ ) : null}
+ {course.page.effort ? (
+ <>
+
+ {course.page.effort}
+
+ >
+ ) : null}
+
+
+ ) : null}
+
+
+
+
+
Free
+
+
+
+
+
+
+ {product ? (
+ <>
+ Certificate track: $
+ {product.price.toLocaleString("en-us", {
+ style: "currency",
+ currency: "en-US"
+ })}
+ {run.upgrade_deadline ? (
+ <>
+
+ Payment deadline:{" "}
+ {formatPrettyDate(moment(run.upgrade_deadline))}
+
+ >
+ ) : null}
+
+ {course.page.financial_assistance_form_url ? (
+
+ ) : null}
+ >
+ ) : (
+ "No certificate available."
+ )}
+
+
+
+ {course && course.programs && course.programs.length > 0 ? (
+
+
+ Part of the following program
+ {course.programs.length === 1 ? null : "s"}
+
+
+
+ {course.programs.map(elem => (
+ <>
+
+ {" "}
+ {elem.title}
+
+ >
+ ))}
+
+
+ ) : null}
+ >
+ )
+ }
+}
diff --git a/frontend/public/src/containers/ProductDetailEnrollApp.js b/frontend/public/src/containers/ProductDetailEnrollApp.js
index b10c7c3f40..7e49617913 100644
--- a/frontend/public/src/containers/ProductDetailEnrollApp.js
+++ b/frontend/public/src/containers/ProductDetailEnrollApp.js
@@ -15,7 +15,10 @@ import { EnrollmentFlaggedCourseRun } from "../flow/courseTypes"
import {
courseRunsSelector,
courseRunsQuery,
- courseRunsQueryKey
+ courseRunsQueryKey,
+ coursesSelector,
+ coursesQuery,
+ coursesQueryKey
} from "../lib/queries/courseRuns"
import { formatPrettyDate, emptyOrNil } from "../lib/util"
@@ -30,12 +33,35 @@ import users, { currentUserSelector } from "../lib/queries/users"
import { enrollmentMutation } from "../lib/queries/enrollment"
import { checkFeatureFlag } from "../lib/util"
import AddlProfileFieldsForm from "../components/forms/AddlProfileFieldsForm"
+import CourseInfoBox from "../components/CourseInfoBox"
+
+import posthog from "posthog-js"
+
+/* global SETTINGS:false */
+posthog.init(SETTINGS.posthog_api_token, {
+ api_host: SETTINGS.posthog_api_host
+})
+
+const expandExpandBlock = (event: MouseEvent) => {
+ const blockTarget = event.target
+
+ if (blockTarget instanceof HTMLElement) {
+ const block = blockTarget.getAttribute("data-expand-body")
+ if (block) {
+ const elem = document.querySelector(`div#exp${block}`)
+ elem && elem.classList && elem.classList.toggle("open")
+ }
+ }
+}
type Props = {
courseId: string,
isLoading: ?boolean,
courseRuns: ?Array,
+ courses: ?Array,
status: ?number,
+ courseIsLoading: ?boolean,
+ courseStatus: ?number,
upgradeEnrollmentDialogVisibility: boolean,
addProductToBasket: (user: number, productId: number) => Promise,
currentUser: User,
@@ -142,7 +168,7 @@ export class ProductDetailEnrollApp extends React.Component<
return this.state.currentCourseRun
}
- renderUpgradeEnrollmentDialog() {
+ renderUpgradeEnrollmentDialog(showNewDesign: boolean) {
const { courseRuns } = this.props
const run =
!this.getCurrentCourseRun() && courseRuns
@@ -151,7 +177,7 @@ export class ProductDetailEnrollApp extends React.Component<
const needFinancialAssistanceLink =
isFinancialAssistanceAvailable(run) &&
!run.approved_flexible_price_exists ? (
-
+
Need financial assistance?
@@ -161,64 +187,154 @@ export class ProductDetailEnrollApp extends React.Component<
const product = run.products ? run.products[0] : null
return product ? (
- this.toggleUpgradeDialogVisibility()}
- >
- this.toggleUpgradeDialogVisibility()}>
- Enroll
-
-
-
-
- Learn online and get a certificate
+ showNewDesign ? (
+
this.toggleUpgradeDialogVisibility()}
+ centered
+ >
+ this.toggleUpgradeDialogVisibility()}>
+ {run.title}
+
+
+
+
+
+ Thank you for choosing an MITx online course. By paying for
+ this course, you're joining the most engaged and motivated
+ learners on your path to a certificate from MITx.
+
+
-
- {formatLocalePrice(getFlexiblePriceForProduct(product))}
+
+
+
+
+ Acheiving a certificate has its advantages:
+
+
-
-
-
-
- Thank you for choosing an MITx online course. By paying for this
- course, you're joining the most engaged and motivated learners
- on your path to a certificate from MITx.
-
-
- Your certificate is signed by MIT faculty and demonstrates that
- you have gained the knowledge and skills taught in this course.
- Showcase your certificate on your resume and social channels to
- advance your career, earn a promotion, or enhance your college
- applications.
-
+
+
+
+ Certificate is signed by MIT faculty
+
+ {" "}
+ Demonstrates knowledge and skills taught in this course
+
+ Enhance your college & earn a promotion
+
+
+
+
+ Highlight on your resume/CV
+ Share on your social channels & LinkedIn
+
+ Enhance your college application with an earned certificate
+ from MIT
+
+
+
+
-
- {needFinancialAssistanceLink}
+
+
+
+ Certitficate track:{" "}
+
+ ${product && formatLocalePrice(product.price)}
+
+ {run.upgrade_deadline ? (
+ <>
+
+
+ Payment date:{" "}
+ {formatPrettyDate(moment(run.upgrade_deadline))}
+
+ >
+ ) : null}
+
+
+
{needFinancialAssistanceLink}
+
+
+
+
-
-
{this.getEnrollmentForm()}
-
-
-
+
+
{this.getEnrollmentForm()}
+
+
+ ) : (
+
this.toggleUpgradeDialogVisibility()}
+ >
+ this.toggleUpgradeDialogVisibility()}>
+ Enroll
+
+
+
+
+ Learn online and get a certificate
+
+
+ {formatLocalePrice(getFlexiblePriceForProduct(product))}
+
+
+
+
+
+ Thank you for choosing an MITx online course. By paying for
+ this course, you're joining the most engaged and motivated
+ learners on your path to a certificate from MITx.
+
+
+
+ Your certificate is signed by MIT faculty and demonstrates
+ that you have gained the knowledge and skills taught in this
+ course. Showcase your certificate on your resume and social
+ channels to advance your career, earn a promotion, or enhance
+ your college applications.
+
+
+
+ {needFinancialAssistanceLink}
+
+
+ {this.getEnrollmentForm()}
+
+
+
+ )
) : null
}
@@ -282,8 +398,15 @@ export class ProductDetailEnrollApp extends React.Component<
}
render() {
- const { courseRuns, isLoading, currentUser } = this.props
+ const {
+ courseRuns,
+ isLoading,
+ courses,
+ courseIsLoading,
+ currentUser
+ } = this.props
const csrfToken = getCookie("csrftoken")
+
let run =
!this.getCurrentCourseRun() && courseRuns
? courseRuns[0]
@@ -324,78 +447,113 @@ export class ProductDetailEnrollApp extends React.Component<
) : null
+ const showNewDesign = checkFeatureFlag("mitxonline-new-product-page")
+
+ if (showNewDesign) {
+ document.querySelectorAll("a.expand_here_link").forEach(link => {
+ link.removeEventListener("click", expandExpandBlock)
+ link.addEventListener("click", expandExpandBlock)
+ })
+ }
+
return (
- // $FlowFixMe: isLoading null or undefined
-
- {run && run.is_enrolled ? (
-
- {run.courseware_url ? (
-
- run
- ? this.redirectToCourseHomepage(run.courseware_url, ev)
- : ev
- }
- className={`btn btn-primary btn-gradient-red highlight outline ${disableEnrolledBtn}`}
- target="_blank"
- rel="noopener noreferrer"
- >
- Enrolled ✓
-
- ) : (
-
- Enrolled ✓
-
- )}
- {waitingForCourseToBeginMessage}
-
- ) : (
-
- {run &&
- isWithinEnrollmentPeriod(run) &&
- currentUser &&
- !currentUser.id ? (
-
- Enroll now
-
- ) : run && isWithinEnrollmentPeriod(run) ? (
- product && run.is_upgradable ? (
- this.toggleUpgradeDialogVisibility()}
- >
- Enroll now
-
- ) : (
-
-
-
- )
- ) : null}
- {run ? this.renderUpgradeEnrollmentDialog() : null}
-
- )}
- {currentUser ? this.renderAddlProfileFieldsModal() : null}
-
+
+ ) : run && isWithinEnrollmentPeriod(run) ? (
+ product && run.is_upgradable ? (
+
this.toggleUpgradeDialogVisibility()}
+ >
+ Enroll now
+
+ ) : (
+
+
+
+ )
+ ) : null}
+ {run
+ ? this.renderUpgradeEnrollmentDialog(showNewDesign)
+ : null}
+
+ )}
+
+ {currentUser ? this.renderAddlProfileFieldsModal() : null}
+ >
+
+ }
+ {showNewDesign ? (
+ <>
+ {
+ // $FlowFixMe: isLoading null or undefined
+
+
+
+ }
+ >
+ ) : null}
+ >
)
}
}
@@ -418,14 +576,18 @@ const updateAddlFields = (currentUser: User) => {
}
const mapStateToProps = createStructuredSelector({
- courseRuns: courseRunsSelector,
- currentUser: currentUserSelector,
- isLoading: pathOr(true, ["queries", courseRunsQueryKey, "isPending"]),
- status: pathOr(null, ["queries", courseRunsQueryKey, "status"])
+ courseRuns: courseRunsSelector,
+ courses: coursesSelector,
+ currentUser: currentUserSelector,
+ isLoading: pathOr(true, ["queries", courseRunsQueryKey, "isPending"]),
+ courseIsLoading: pathOr(true, ["queries", coursesQueryKey, "isPending"]),
+ status: pathOr(null, ["queries", courseRunsQueryKey, "status"]),
+ courseStatus: pathOr(true, ["queries", coursesQueryKey, "status"])
})
const mapPropsToConfig = props => [
courseRunsQuery(props.courseId),
+ coursesQuery(props.courseId),
users.currentUserQuery()
]
diff --git a/frontend/public/src/containers/ProductDetailEnrollApp_test.js b/frontend/public/src/containers/ProductDetailEnrollApp_test.js
index 594702fed8..b1e60150bb 100644
--- a/frontend/public/src/containers/ProductDetailEnrollApp_test.js
+++ b/frontend/public/src/containers/ProductDetailEnrollApp_test.js
@@ -39,7 +39,6 @@ describe("ProductDetailEnrollApp", () => {
helper = new IntegrationTestHelper()
courseRun = makeCourseRunDetailWithProduct()
currentUser = makeUser()
- courseRun["products"] = [{ id: 1 }]
renderPage = helper.configureHOCRenderer(
ProductDetailEnrollApp,
InnerProductDetailEnrollApp,
@@ -52,8 +51,9 @@ describe("ProductDetailEnrollApp", () => {
{}
)
SETTINGS.features = {
- enable_learner_records: false,
- enable_addl_profile_fields: false
+ enable_learner_records: false,
+ enable_addl_profile_fields: false,
+ "mitxonline-new-product-page": false
}
isWithinEnrollmentPeriodStub = helper.sandbox.stub(
@@ -79,7 +79,6 @@ describe("ProductDetailEnrollApp", () => {
}
})
- assert.isTrue(inner.props().isLoading)
const loader = inner.find("Loader")
assert.isOk(loader.exists())
assert.isTrue(loader.props().isLoading)
diff --git a/frontend/public/src/entry/style.js b/frontend/public/src/entry/style.js
index 70f041ce08..3032f21031 100644
--- a/frontend/public/src/entry/style.js
+++ b/frontend/public/src/entry/style.js
@@ -1 +1,6 @@
+import { checkFeatureFlag } from "../lib/util"
import "../../scss/layout.scss"
+
+if (checkFeatureFlag("mitxonline-new-product-page")) {
+ import("../../scss/meta-product-page.scss")
+}
diff --git a/frontend/public/src/flow/cmsTypes.js b/frontend/public/src/flow/cmsTypes.js
index ae75cf0fc4..63ed8caa71 100644
--- a/frontend/public/src/flow/cmsTypes.js
+++ b/frontend/public/src/flow/cmsTypes.js
@@ -3,3 +3,12 @@ export type CoursePage = {
page_url: string,
live: boolean,
}
+
+export type InstructorPage = {
+ id: number,
+ instructor_name: string,
+ isntructor_title: string,
+ instructor_bio_short: string,
+ instructor_bio_long: ?string,
+ feature_image_src: string,
+}
diff --git a/frontend/public/src/flow/declarations.js b/frontend/public/src/flow/declarations.js
index 5a0b872b95..976cf0c804 100644
--- a/frontend/public/src/flow/declarations.js
+++ b/frontend/public/src/flow/declarations.js
@@ -21,7 +21,9 @@ declare type Settings = {
help_widget_key: ?string
},
digital_credentials: boolean,
- digital_credentials_supported_runs: Array
+ digital_credentials_supported_runs: Array,
+ posthog_api_token: ?string,
+ posthog_api_host: ?string
}
declare var SETTINGS: Settings
diff --git a/frontend/public/src/lib/queries/cms.js b/frontend/public/src/lib/queries/cms.js
new file mode 100644
index 0000000000..eaad666d26
--- /dev/null
+++ b/frontend/public/src/lib/queries/cms.js
@@ -0,0 +1,20 @@
+import { nextState } from "./util"
+import { pathOr } from "ramda"
+
+export const instructorPageSelector = pathOr(null, [
+ "entites",
+ "instructorPage"
+])
+
+export const instructorPageQueryKey = "instructorPage"
+
+export const instructorPageQuery = (pageId: any) => ({
+ queryKey: instructorPageQueryKey,
+ url: `/api/instructor/${pageId}/`,
+ transform: json => ({
+ instructorPage: json
+ }),
+ update: {
+ instructorPage: nextState
+ }
+})
diff --git a/frontend/public/src/lib/queries/courseRuns.js b/frontend/public/src/lib/queries/courseRuns.js
index bb2caef267..013a5d5b9d 100644
--- a/frontend/public/src/lib/queries/courseRuns.js
+++ b/frontend/public/src/lib/queries/courseRuns.js
@@ -3,8 +3,10 @@ import { pathOr } from "ramda"
import { nextState } from "./util"
export const courseRunsSelector = pathOr(null, ["entities", "courseRuns"])
+export const coursesSelector = pathOr(null, ["entities", "courses"])
export const courseRunsQueryKey = "courseRuns"
+export const coursesQueryKey = "courses"
export const courseRunsQuery = (courseKey: string = "") => ({
queryKey: courseRunsQueryKey,
@@ -16,3 +18,14 @@ export const courseRunsQuery = (courseKey: string = "") => ({
courseRuns: nextState
}
})
+
+export const coursesQuery = (courseKey: string = "") => ({
+ queryKey: coursesQueryKey,
+ url: `/api/courses/?readable_id=${encodeURIComponent(courseKey)}`,
+ transform: json => ({
+ courses: json
+ }),
+ update: {
+ courses: nextState
+ }
+})
diff --git a/frontend/public/src/lib/util.js b/frontend/public/src/lib/util.js
index 0d391a2dec..b84f750d18 100644
--- a/frontend/public/src/lib/util.js
+++ b/frontend/public/src/lib/util.js
@@ -18,6 +18,7 @@ import _truncate from "lodash/truncate"
import qs from "query-string"
import * as R from "ramda"
import moment from "moment-timezone"
+import posthog from "posthog-js"
import type Moment from "moment"
import type { HttpRespErrorMessage, HttpResponse } from "../flow/httpTypes"
@@ -29,6 +30,15 @@ import {
DISCOUNT_TYPE_FIXED_PRICE
} from "../constants"
+if (SETTINGS.posthog_api_host && SETTINGS.posthog_api_token) {
+ posthog.init(SETTINGS.posthog_api_token, {
+ api_host: SETTINGS.posthog_api_host
+ })
+ posthog.setPersonPropertiesForFlags({
+ environment: SETTINGS.environment
+ })
+}
+
/**
* Returns a promise which resolves after a number of milliseconds have elapsed
*/
@@ -244,6 +254,7 @@ export const intCheckFeatureFlag = (
const params = new URLSearchParams(document.location.search)
return (
+ (SETTINGS.posthog_api_host && posthog.isFeatureEnabled(flag)) ||
params.get(flag) !== null ||
(settings && settings.features && settings.features[flag])
)
diff --git a/main/features.py b/main/features.py
index 07097c1b62..5698315f5f 100644
--- a/main/features.py
+++ b/main/features.py
@@ -1,11 +1,20 @@
"""MITxOnline feature flags"""
+import os
from functools import wraps
from django.conf import settings
+from posthog import Posthog
+
+if "IN_TEST_SUITE" not in os.environ:
+ posthog = Posthog(settings.POSTHOG_API_TOKEN, host=settings.POSTHOG_API_HOST)
+else:
+ posthog = None
+
IGNORE_EDX_FAILURES = "IGNORE_EDX_FAILURES"
SYNC_ON_DASHBOARD_LOAD = "SYNC_ON_DASHBOARD_LOAD"
ENABLE_ADDL_PROFILE_FIELDS = "ENABLE_ADDL_PROFILE_FIELDS"
+ENABLE_NEW_DESIGN = "mitxonline-new-product-page"
def is_enabled(name, default=None):
@@ -19,7 +28,15 @@ def is_enabled(name, default=None):
Returns:
bool: True if the feature flag is enabled
"""
- return settings.FEATURES.get(name, default or settings.FEATURES_DEFAULT)
+
+ return (
+ posthog
+ and posthog.feature_enabled(
+ name,
+ settings.HOSTNAME,
+ person_properties={"environment": settings.ENVIRONMENT},
+ )
+ ) or settings.FEATURES.get(name, default or settings.FEATURES_DEFAULT)
def if_feature_enabled(name, default=None):
diff --git a/main/urls.py b/main/urls.py
index 1e44d26dcc..b8e88033ee 100644
--- a/main/urls.py
+++ b/main/urls.py
@@ -19,10 +19,11 @@
from django.contrib import admin
from django.urls import path, re_path
from oauth2_provider.urls import base_urlpatterns, oidc_urlpatterns
-from wagtail.admin import urls as wagtailadmin_urls
from wagtail import urls as wagtail_urls
+from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
+from cms.views import instructor_page
from main.views import cms_signin_redirect_to_site_signin, index, refine
handler500 = "main.views.handler500"
@@ -76,6 +77,7 @@
re_path(r"^orders/history/.*", index, name="order-history"),
re_path(r"^orders/receipt/.*", index, name="order-receipt"),
re_path(r"^records/.*", index, name="learner-records"),
+ path("api/instructor//", instructor_page, name="cms_instructor_page"),
# Wagtail
re_path(
r"^cms/login", cms_signin_redirect_to_site_signin, name="wagtailadmin_login"
diff --git a/main/views.py b/main/views.py
index dfc686b162..af190bd5b7 100644
--- a/main/views.py
+++ b/main/views.py
@@ -10,16 +10,20 @@
from django.views.decorators.cache import never_cache
from rest_framework.pagination import LimitOffsetPagination
+from main.features import is_enabled
+
def get_base_context(request):
"""
Returns the template context key/values needed for the base template and all templates that extend it
"""
- context = {}
+ context = {"new_design": is_enabled("mitxonline-new-product-page", False)}
+
if settings.GOOGLE_DOMAIN_VERIFICATION_TAG_VALUE:
context[
"domain_verification_tag"
] = settings.GOOGLE_DOMAIN_VERIFICATION_TAG_VALUE
+
return context
diff --git a/pytest.ini b/pytest.ini
index 94c85f9325..f4b4584137 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -22,3 +22,4 @@ env =
SENTRY_DSN=
RECAPTCHA_SITE_KEY=
RECAPTCHA_SECRET_KEY=
+ IN_TEST_SUITE=True
diff --git a/static/images/arrow-line-right.png b/static/images/arrow-line-right.png
new file mode 100644
index 0000000000..4db9fe6d5c
Binary files /dev/null and b/static/images/arrow-line-right.png differ
diff --git a/static/images/instructor-modal-head.jpg b/static/images/instructor-modal-head.jpg
new file mode 100644
index 0000000000..108fb29e93
Binary files /dev/null and b/static/images/instructor-modal-head.jpg differ
diff --git a/static/images/products/certificate.png b/static/images/products/certificate.png
new file mode 100644
index 0000000000..5ba22ade99
Binary files /dev/null and b/static/images/products/certificate.png differ
diff --git a/static/images/products/cost.png b/static/images/products/cost.png
new file mode 100644
index 0000000000..6ff55f9cd2
Binary files /dev/null and b/static/images/products/cost.png differ
diff --git a/static/images/products/effort.png b/static/images/products/effort.png
new file mode 100644
index 0000000000..6e8f2d8dfc
Binary files /dev/null and b/static/images/products/effort.png differ
diff --git a/static/images/products/start-date.png b/static/images/products/start-date.png
new file mode 100644
index 0000000000..2972ff53d9
Binary files /dev/null and b/static/images/products/start-date.png differ
diff --git a/static/images/upsell-check.png b/static/images/upsell-check.png
new file mode 100644
index 0000000000..5bc35c4b1a
Binary files /dev/null and b/static/images/upsell-check.png differ