diff --git a/frontends/main/public/shopping-bag-icon.svg b/frontends/main/public/shopping-bag-icon.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx index 7e4e4a594c..f37c034bbc 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx @@ -38,6 +38,14 @@ const UnderlinedLink = styled(ResponsiveLink)({ textDecoration: "underline", }) +const SecondaryUnderlinedLink = styled(UnderlinedLink)(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, + [theme.breakpoints.down("sm")]: { + ...theme.typography.body4, + }, +})) + const InfoRow = styled.div(({ theme }) => ({ width: "100%", display: "flex", @@ -378,6 +386,142 @@ const GrayText = styled.span(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, })) +const ProgramPaySection = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: "4px", + width: "346px", + alignSelf: "stretch", + flex: "none", + color: theme.custom.colors.darkGray2, +})) + +const ProgramPayLabel = styled.span(({ theme }) => ({ + ...theme.typography.body4, + fontSize: "12px", + lineHeight: "16px", + fontWeight: theme.typography.fontWeightMedium, + color: theme.custom.colors.silverGrayDark, + textTransform: "uppercase", + letterSpacing: "0.04em", + width: "100%", + alignSelf: "stretch", +})) + +const ProgramPayContent = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: "12px", + width: "100%", + maxWidth: "320px", + [theme.breakpoints.down("sm")]: { + maxWidth: "100%", + }, +})) + +const ProgramPriceLine = styled.div(({ theme }) => ({ + display: "flex", + alignItems: "flex-end", + gap: "4px", + flexWrap: "wrap", + color: theme.custom.colors.darkGray2, +})) + +const ProgramPriceAmount = styled.span(({ theme }) => ({ + ...theme.typography.h3, + fontWeight: theme.typography.fontWeightBold, + lineHeight: "36px", +})) + +const ProgramPriceSuffix = styled.span(({ theme }) => ({ + ...theme.typography.subtitle1, + fontWeight: theme.typography.fontWeightMedium, + fontSize: "18px", + lineHeight: "26px", + color: theme.custom.colors.silverGrayDark, +})) + +const ProgramDiscountBlock = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "flex-start", + gap: "8px", + color: theme.custom.colors.darkGray2, +})) + +const ProgramSavingsText = styled.span(({ theme }) => ({ + ...theme.typography.subtitle2, + color: "#008000", + fontSize: "16px", + lineHeight: "24px", + fontWeight: theme.typography.fontWeightBold, +})) + +const ProgramListPriceText = styled.span(({ theme }) => ({ + ...theme.typography.body2, + fontSize: "14px", + lineHeight: "18px", + fontWeight: theme.typography.fontWeightRegular, + color: theme.custom.colors.silverGrayDark, +})) + +const ProgramListPriceAmount = styled.span(({ theme }) => ({ + fontStyle: "normal", + fontSize: "14px", + lineHeight: "18px", + letterSpacing: "0", + verticalAlign: "bottom", + textDecoration: "line-through", + fontWeight: theme.typography.fontWeightMedium, +})) + +const ProgramPriceDivider = styled.div(({ theme }) => ({ + width: "100%", + maxWidth: "346px", + borderTop: `1px solid ${theme.custom.colors.lightGray2}`, + marginBottom: "20px", + flex: "none", + alignSelf: "stretch", +})) + +const ProgramStartForFreeBox = styled.div((_theme) => ({ + display: "flex", + alignItems: "center", + gap: "8px", + padding: "8px 16px", + borderRadius: "8px", + background: + "linear-gradient(0deg, rgba(255, 255, 255, 0.94), rgba(255, 255, 255, 0.94)), #004D1A", +})) + +const ProgramStartForFreeIcon = styled.svg(() => ({ + width: "24px", + height: "24px", + flexShrink: 0, + path: { + fill: "#008000", + }, +})) + +const ProgramStartForFreeTextContainer = styled.span(({ theme }) => ({ + display: "flex", + alignItems: "center", + gap: "4px", + ...theme.typography.body2, +})) + +const ProgramStartForFreeTextStrong = styled.span(({ theme }) => ({ + color: "#008000", + fontWeight: theme.typography.fontWeightMedium, +})) + +const ProgramStartForFreeTextRegular = styled.span(({ theme }) => ({ + color: theme.custom.colors.darkGray2, +})) + const CertificateBoxRoot = styled.div(({ theme }) => ({ width: "100%", backgroundColor: theme.custom.colors.lightGray1, @@ -657,15 +801,16 @@ type ProgramInfoRowProps = { program: V2ProgramDetail } & HTMLAttributes +const getTotalRequiredCourses = (program: V2ProgramDetail) => { + const parsedReqs = parseReqTree(program.req_tree) + return parsedReqs.reduce((sum, req) => sum + req.requiredCount, 0) +} + const RequirementsRow: React.FC = ({ program, ...others }) => { - const parsedReqs = parseReqTree(program.req_tree) - const totalRequired = parsedReqs.reduce( - (sum, req) => sum + req.requiredCount, - 0, - ) + const totalRequired = getTotalRequiredCourses(program) if (totalRequired === 0) return null // Always say "Courses" here. Whether a child program should be labeled @@ -760,41 +905,6 @@ const ProgramPaceRow: React.FC< const PROGRAM_CERT_INFO_HREF = "https://mitxonline.zendesk.com/hc/en-us/articles/28158506908699-What-is-the-Certificate-Track-What-are-Course-and-Program-Certificates" -const ProgramCertificateBox: React.FC<{ program: V2ProgramDetail }> = ({ - program, -}) => { - const price = program.products[0]?.price - if (!price) return null - return ( - - - - - Earn a certificate - - : {formatPrice(price, { avoidCents: true })} - - - {program.page.financial_assistance_form_url ? ( - - Financial assistance available - - ) : null} - - ) -} - type ProgramPriceRowProps = HTMLAttributes & { program: V2ProgramDetail } @@ -805,33 +915,113 @@ const ProgramPriceRow: React.FC = ({ const enrollmentType = getEnrollmentType(program.enrollment_modes) if (enrollmentType === "none") return null - const paidPrice = - enrollmentType === "paid" && program.products[0]?.price ? ( - <> - {formatPrice(program.products[0].price, { avoidCents: true })}{" "} - (includes {program.certificate_type}) - - ) : null + const currentPrice = program.products[0]?.price + const listPrice = program.page.list_price + + const currentAmount = toNumericPrice(currentPrice) + const listAmount = toNumericPrice(listPrice) + const hasSavings = + currentAmount !== null && listAmount !== null && listAmount > currentAmount + const savingsAmount = hasSavings ? listAmount - currentAmount : null + + const totalRequired = getTotalRequiredCourses(program) + + const paidSection = currentPrice ? ( + + Price + + + + {formatPrice(currentPrice, { avoidCents: true })} + + / full program + + {hasSavings && savingsAmount !== null && listAmount !== null ? ( + + + Save {formatPrice(savingsAmount, { avoidCents: true })} + + + + {formatPrice(listAmount, { avoidCents: true })} + {" "} + total for {totalRequired} {pluralize("course", totalRequired)}{" "} + purchased separately + + + ) : null} + {program.page.financial_assistance_form_url ? ( + + Financial assistance available + + ) : null} + {enrollmentType === "both" ? ( + + + + + Start for free + + + or upgrade to certificate + + + + ) : null} + + + ) : ( + + ) return ( - - - - - {enrollmentType === "paid" ? ( - + + {enrollmentType === "paid" || enrollmentType === "both" ? ( + + ) : null} + + {enrollmentType === "paid" || enrollmentType === "both" ? ( + paidSection ) : ( - + <> + + + + + + )} - {enrollmentType === "both" ? ( - - ) : null} - - + + ) } +const toNumericPrice = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string") { + const parsed = Number.parseFloat(value) + if (Number.isFinite(parsed)) return parsed + } + return null +} + const ProgramSummary: React.FC<{ program: V2ProgramDetail /**