@@ -378,6 +378,83 @@ const GrayText = styled.span(({ theme }) => ({
378378 color : theme . custom . colors . silverGrayDark ,
379379} ) )
380380
381+ const ProgramPaySection = styled . div ( ( { theme } ) => ( {
382+ display : "flex" ,
383+ flexDirection : "column" ,
384+ alignItems : "flex-start" ,
385+ gap : "4px" ,
386+ width : "100%" ,
387+ maxWidth : "346px" ,
388+ color : theme . custom . colors . darkGray2 ,
389+ } ) )
390+
391+ const ProgramPayLabel = styled . span ( ( { theme } ) => ( {
392+ ...theme . typography . body4 ,
393+ fontWeight : theme . typography . fontWeightMedium ,
394+ color : theme . custom . colors . silverGrayDark ,
395+ textTransform : "uppercase" ,
396+ letterSpacing : "0.04em" ,
397+ } ) )
398+
399+ const ProgramPayContent = styled . div ( ( { theme } ) => ( {
400+ display : "flex" ,
401+ flexDirection : "column" ,
402+ alignItems : "flex-start" ,
403+ gap : "12px" ,
404+ width : "100%" ,
405+ maxWidth : "320px" ,
406+ [ theme . breakpoints . down ( "sm" ) ] : {
407+ maxWidth : "100%" ,
408+ } ,
409+ } ) )
410+
411+ const ProgramPriceLine = styled . div ( ( { theme } ) => ( {
412+ display : "flex" ,
413+ alignItems : "flex-end" ,
414+ gap : "4px" ,
415+ flexWrap : "wrap" ,
416+ color : theme . custom . colors . darkGray2 ,
417+ } ) )
418+
419+ const ProgramPriceAmount = styled . span ( ( { theme } ) => ( {
420+ ...theme . typography . h3 ,
421+ fontWeight : theme . typography . fontWeightBold ,
422+ lineHeight : "36px" ,
423+ } ) )
424+
425+ const ProgramPriceSuffix = styled . span ( ( { theme } ) => ( {
426+ ...theme . typography . subtitle1 ,
427+ fontWeight : theme . typography . fontWeightMedium ,
428+ color : theme . custom . colors . silverGrayDark ,
429+ } ) )
430+
431+ const ProgramDiscountBlock = styled . div ( ( { theme } ) => ( {
432+ display : "flex" ,
433+ flexDirection : "column" ,
434+ alignItems : "flex-start" ,
435+ gap : "4px" ,
436+ color : theme . custom . colors . darkGray2 ,
437+ } ) )
438+
439+ const ProgramSavingsText = styled . span ( ( { theme } ) => ( {
440+ ...theme . typography . subtitle2 ,
441+ color : theme . custom . colors . green ,
442+ } ) )
443+
444+ const ProgramListPriceText = styled . span ( ( { theme } ) => ( {
445+ ...theme . typography . body2 ,
446+ color : theme . custom . colors . silverGrayDark ,
447+ textDecoration : "line-through" ,
448+ } ) )
449+
450+ const ProgramPriceDivider = styled . div ( ( { theme } ) => ( {
451+ width : "100%" ,
452+ maxWidth : "346px" ,
453+ borderTop : `1px solid ${ theme . custom . colors . lightGray2 } ` ,
454+ flex : "none" ,
455+ alignSelf : "stretch" ,
456+ } ) )
457+
381458const CertificateBoxRoot = styled . div ( ( { theme } ) => ( {
382459 width : "100%" ,
383460 backgroundColor : theme . custom . colors . lightGray1 ,
@@ -796,42 +873,89 @@ const ProgramCertificateBox: React.FC<{ program: V2ProgramDetail }> = ({
796873}
797874
798875type ProgramPriceRowProps = HTMLAttributes < HTMLDivElement > & {
799- program : V2ProgramDetail
876+ program : V2ProgramDetail & {
877+ // Temporary local extension while list_price rolls into upstream API typings.
878+ list_price ?: number | string | null
879+ }
800880}
801881const ProgramPriceRow : React . FC < ProgramPriceRowProps > = ( {
802882 program,
803883 ...others
804884} ) => {
805885 const enrollmentType = getEnrollmentType ( program . enrollment_modes )
806- if ( enrollmentType === "none" ) return null
807-
808- const paidPrice =
809- enrollmentType === "paid" && program . products [ 0 ] ?. price ? (
810- < >
811- { formatPrice ( program . products [ 0 ] . price , { avoidCents : true } ) } { " " }
812- < GrayText > (includes { program . certificate_type } )</ GrayText >
813- </ >
814- ) : null
886+ // if (enrollmentType === "none") return null
887+
888+ const currentPrice = program . products [ 0 ] ?. price
889+ const listPrice = program . list_price
890+
891+ const currentAmount = toNumericPrice ( currentPrice )
892+ const listAmount = toNumericPrice ( listPrice )
893+ const hasSavings =
894+ currentAmount !== null && listAmount !== null && listAmount > currentAmount
895+ const savingsAmount = hasSavings ? listAmount - currentAmount : null
896+
897+ const paidSection =
898+ enrollmentType === "paid" && currentPrice ? (
899+ < ProgramPaySection >
900+ < ProgramPayLabel > Price</ ProgramPayLabel >
901+ < ProgramPayContent >
902+ < ProgramPriceLine >
903+ < ProgramPriceAmount >
904+ { formatPrice ( currentPrice , { avoidCents : true } ) }
905+ </ ProgramPriceAmount >
906+ < ProgramPriceSuffix > / full program</ ProgramPriceSuffix >
907+ </ ProgramPriceLine >
908+ { hasSavings && savingsAmount !== null && listAmount !== null ? (
909+ < ProgramDiscountBlock >
910+ < ProgramSavingsText >
911+ Save { formatPrice ( savingsAmount , { avoidCents : true } ) }
912+ </ ProgramSavingsText >
913+ < ProgramListPriceText >
914+ { formatPrice ( listAmount , { avoidCents : true } ) } total for
915+ courses purchased separately
916+ </ ProgramListPriceText >
917+ </ ProgramDiscountBlock >
918+ ) : null }
919+ < GrayText > (includes { program . certificate_type } )</ GrayText >
920+ </ ProgramPayContent >
921+ </ ProgramPaySection >
922+ ) : (
923+ < InfoLabelValue label = "Price" value = "Price unavailable" />
924+ )
815925
816926 return (
817- < InfoRow { ...others } >
818- < InfoRowIcon >
819- < RiPriceTag3Line aria-hidden = "true" />
820- </ InfoRowIcon >
821- < InfoRowInner >
927+ < Stack { ...others } gap = "8px" width = "100%" >
928+ { enrollmentType === "paid" ? < ProgramPriceDivider /> : null }
929+ < InfoRow >
822930 { enrollmentType === "paid" ? (
823- < InfoLabelValue label = "Price" value = { paidPrice } />
931+ paidSection
824932 ) : (
825- < InfoLabelValue label = "Price" value = "Free to Learn" />
933+ < >
934+ < InfoRowIcon >
935+ < RiPriceTag3Line aria-hidden = "true" />
936+ </ InfoRowIcon >
937+ < InfoRowInner >
938+ < InfoLabelValue label = "Price" value = "Free to Learn" />
939+ { enrollmentType === "both" ? (
940+ < ProgramCertificateBox program = { program } />
941+ ) : null }
942+ </ InfoRowInner >
943+ </ >
826944 ) }
827- { enrollmentType === "both" ? (
828- < ProgramCertificateBox program = { program } />
829- ) : null }
830- </ InfoRowInner >
831- </ InfoRow >
945+ </ InfoRow >
946+ </ Stack >
832947 )
833948}
834949
950+ const toNumericPrice = ( value : unknown ) : number | null => {
951+ if ( typeof value === "number" && Number . isFinite ( value ) ) return value
952+ if ( typeof value === "string" ) {
953+ const parsed = Number . parseFloat ( value )
954+ if ( Number . isFinite ( parsed ) ) return parsed
955+ }
956+ return null
957+ }
958+
835959const ProgramSummary : React . FC < {
836960 program : V2ProgramDetail
837961 /**
0 commit comments