Skip to content

Commit 09b442d

Browse files
oliverabrahamscharleycampbellemma-imber
authored
Merge pull request #28208 from guardian/cc/product-element
Add product element Co-authored-by: Charley_Campbell <[email protected]> Co-authored-by: Emma Imber <[email protected]>
2 parents ba2abbd + aa9cd72 commit 09b442d

File tree

7 files changed

+205
-6
lines changed

7 files changed

+205
-6
lines changed

common/app/conf/switches/FeatureSwitches.scala

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,4 +638,15 @@ trait FeatureSwitches {
638638
exposeClientSide = true,
639639
highImpact = false,
640640
)
641+
642+
val ProductLeftColCards = Switch(
643+
SwitchGroup.Feature,
644+
"product-left-col-cards",
645+
"Enables product element summary cards to be shown in the left column at wide breakpoints",
646+
owners = Seq(Owner.withEmail("[email protected]")),
647+
sellByDate = never,
648+
safeState = Off,
649+
exposeClientSide = true,
650+
highImpact = false,
651+
)
641652
}

common/app/model/dotcomrendering/ElementsEnhancer.scala

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ object ElementsEnhancer {
1919
elementType match {
2020
case "model.dotcomrendering.pageElements.ListBlockElement" => enhanceListBlockElement(elementWithId)
2121
case "model.dotcomrendering.pageElements.TimelineBlockElement" => enhanceTimelineBlockElement(elementWithId)
22+
case "model.dotcomrendering.pageElements.ProductBlockElement" => enhanceProductBlockElement(elementWithId)
2223
case _ => elementWithId;
2324
}
2425
case _ => element
@@ -33,6 +34,10 @@ object ElementsEnhancer {
3334
elementWithId ++ Json.obj("items" -> listItemsWithIds)
3435
}
3536

37+
def enhanceProductBlockElement(elementWithId: JsObject): JsObject = {
38+
elementWithId ++ Json.obj("content" -> enhanceElements(elementWithId.value("content")))
39+
}
40+
3641
def enhanceTimelineBlockElement(element: JsObject): JsObject = {
3742
val sectionsList = element.value("sections").as[List[JsObject]]
3843
val sectionsListWithIds = sectionsList.map { section =>

common/app/model/dotcomrendering/pageElements/PageElement.scala

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import com.gu.contentapi.client.model.v1.EmbedTracksType.DoesNotTrack
66
import com.gu.contentapi.client.model.v1.{
77
EmbedTracking,
88
LinkType,
9+
ProductDisplayType,
10+
ProductElementFields,
11+
ProductCTA => ApiProductCta,
12+
ProductCustomAttribute => ApiProductCustomAttribute,
13+
ProductImage => ApiProductImage,
914
SponsorshipType,
1015
TimelineElementFields,
1116
WitnessElementFields,
@@ -510,6 +515,48 @@ object LinkBlockElement {
510515
implicit val LinkBlockElementWrites: Writes[LinkBlockElement] = Json.writes[LinkBlockElement]
511516
}
512517

518+
case class ProductImage(
519+
url: String,
520+
caption: String,
521+
height: Int,
522+
width: Int,
523+
alt: String,
524+
credit: String,
525+
displayCredit: Boolean,
526+
)
527+
case class ProductCustomAttribute(
528+
name: String,
529+
value: String,
530+
)
531+
case class ProductCta(
532+
text: String,
533+
price: String,
534+
retailer: String,
535+
url: String,
536+
)
537+
case class ProductBlockElement(
538+
productName: String,
539+
brandName: String,
540+
primaryHeadingHtml: String,
541+
secondaryHeadingHtml: String,
542+
starRating: String,
543+
productCtas: List[ProductCta],
544+
customAttributes: List[ProductCustomAttribute],
545+
image: Option[ProductImage],
546+
content: Seq[PageElement],
547+
displayType: ProductDisplayType,
548+
) extends PageElement
549+
object ProductBlockElement {
550+
implicit val ProductBlockElementImageWrites: Writes[ProductImage] = Json.writes[ProductImage]
551+
implicit val ProductBlockElementCTAWrites: Writes[ProductCta] = Json.writes[ProductCta]
552+
implicit val ProductBlockElementCustomAttributeWrites: Writes[ProductCustomAttribute] =
553+
Json.writes[ProductCustomAttribute]
554+
implicit val ProductBlockElementDisplayTypeWrites: Writes[ProductDisplayType] = Writes { displayType =>
555+
JsString(displayType.name)
556+
}
557+
implicit val ProductBlockElementWrites: Writes[ProductBlockElement] = Json.writes[ProductBlockElement]
558+
}
559+
513560
case class QABlockElement(id: String, title: String, img: Option[String], html: String, credit: String)
514561
extends PageElement
515562
object QABlockElement {
@@ -897,6 +944,7 @@ object PageElement {
897944
case _: ListBlockElement => true
898945
case _: TimelineBlockElement => true
899946
case _: LinkBlockElement => true
947+
case _: ProductBlockElement => true
900948

901949
// TODO we should quick fail here for these rather than pointlessly go to DCR
902950
case table: TableBlockElement if table.isMandatory.exists(identity) => true
@@ -1534,6 +1582,23 @@ object PageElement {
15341582
)
15351583
}.toList
15361584

1585+
case Product =>
1586+
element.productTypeData.map { productTypeData =>
1587+
makeProduct(
1588+
addAffiliateLinks,
1589+
pageUrl,
1590+
atoms,
1591+
isImmersive,
1592+
campaigns,
1593+
calloutsUrl,
1594+
edition,
1595+
webPublicationDate,
1596+
productTypeData,
1597+
isGallery,
1598+
isTheFilterUS,
1599+
)
1600+
}.toList
1601+
15371602
case EnumUnknownElementType(f) => List(UnknownBlockElement(None))
15381603
case _ => Nil
15391604
}
@@ -1643,6 +1708,112 @@ object PageElement {
16431708
)
16441709
}
16451710

1711+
private def makeProduct(
1712+
addAffiliateLinks: Boolean,
1713+
pageUrl: String,
1714+
atoms: Iterable[Atom],
1715+
isImmersive: Boolean,
1716+
campaigns: Option[JsValue],
1717+
calloutsUrl: Option[String],
1718+
edition: Edition,
1719+
webPublicationDate: DateTime,
1720+
product: ProductElementFields,
1721+
isGallery: Boolean,
1722+
isTheFilterUS: Boolean,
1723+
) = {
1724+
1725+
def createProductCta(
1726+
cta: ApiProductCta,
1727+
pageUrl: String,
1728+
addAffiliateLinks: Boolean,
1729+
isTheFilterUS: Boolean,
1730+
): Option[ProductCta] = {
1731+
for {
1732+
// URL must exist and be non-empty
1733+
url <- AffiliateLinksCleaner
1734+
.replaceUrlInLink(cta.url, pageUrl, addAffiliateLinks, isTheFilterUS)
1735+
.filter(_.nonEmpty)
1736+
1737+
// Must have either non-empty text, or both non-empty price & retailer
1738+
if cta.text.exists(_.nonEmpty) ||
1739+
(cta.price.exists(_.nonEmpty) && cta.retailer.exists(_.nonEmpty))
1740+
} yield ProductCta(
1741+
text = cta.text.getOrElse(""),
1742+
price = cta.price.getOrElse(""),
1743+
retailer = cta.retailer.getOrElse(""),
1744+
url = url,
1745+
)
1746+
}
1747+
1748+
def createProductCustomAttribute(apiCustomAttribute: ApiProductCustomAttribute): Option[ProductCustomAttribute] = {
1749+
for {
1750+
name <- apiCustomAttribute.name if name.nonEmpty
1751+
value <- apiCustomAttribute.value if value.nonEmpty
1752+
} yield ProductCustomAttribute(
1753+
name = name,
1754+
value = value,
1755+
)
1756+
}
1757+
1758+
def createProductImage(apiImage: ApiProductImage): Option[ProductImage] = {
1759+
for {
1760+
url <- apiImage.file if url.nonEmpty
1761+
height <- apiImage.height
1762+
width <- apiImage.width
1763+
displayCredit <- apiImage.displayCredit
1764+
credit <- apiImage.credit
1765+
alt <- apiImage.alt
1766+
} yield ProductImage(
1767+
url = url,
1768+
caption = apiImage.caption.getOrElse(""),
1769+
credit = credit,
1770+
height = height,
1771+
width = width,
1772+
displayCredit = displayCredit,
1773+
alt = alt,
1774+
)
1775+
}
1776+
1777+
ProductBlockElement(
1778+
content = product.content
1779+
.getOrElse(List())
1780+
.flatMap { element =>
1781+
PageElement.make(
1782+
element,
1783+
addAffiliateLinks,
1784+
pageUrl,
1785+
atoms,
1786+
isMainBlock = false,
1787+
isImmersive,
1788+
campaigns,
1789+
calloutsUrl,
1790+
overrideImage = None,
1791+
edition,
1792+
webPublicationDate,
1793+
isGallery,
1794+
isTheFilterUS,
1795+
)
1796+
}
1797+
.toSeq,
1798+
productName = product.productName.getOrElse(""),
1799+
brandName = product.brandName.getOrElse(""),
1800+
primaryHeadingHtml = product.primaryHeading.getOrElse(""),
1801+
secondaryHeadingHtml = product.secondaryHeading.getOrElse(""),
1802+
starRating = product.starRating.getOrElse("none-selected"),
1803+
productCtas = product.productCtas
1804+
.getOrElse(Seq.empty)
1805+
.flatMap(cta => createProductCta(cta, pageUrl, addAffiliateLinks, isTheFilterUS))
1806+
.toList,
1807+
customAttributes = product.customAttributes
1808+
.getOrElse(Seq.empty)
1809+
.flatMap(apiAttr => createProductCustomAttribute(apiAttr))
1810+
.toList,
1811+
image = product.image.flatMap(apiImage => createProductImage(apiImage)),
1812+
displayType = product.displayType,
1813+
)
1814+
1815+
}
1816+
16461817
private[this] def ensureHTTPS(url: String): String = {
16471818
val http = "http://"
16481819

common/app/model/liveblog/BlockElement.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ object BlockElement {
183183
case ElementType.List => Some(UnsupportedBlockElement(None))
184184
case Timeline => Some(UnsupportedBlockElement(None))
185185
case Link => Some(UnsupportedBlockElement(None))
186+
case Product => Some(UnsupportedBlockElement(None))
186187
}
187188
}
188189

common/app/views/support/HtmlCleaner.scala

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -910,18 +910,21 @@ object AffiliateLinksCleaner {
910910
isTheFilterUS: Boolean,
911911
): Option[String] = {
912912
val skimlinksId = if (isTheFilterUS) skimlinksUSId else skimlinksDefaultId
913-
url match {
913+
val httpsUrl = url.map(ensureHttps)
914+
httpsUrl match {
914915
case Some(link) if addAffiliateLinks && SkimLinksCache.isSkimLink(link) =>
915916
Some(linkToSkimLink(link, pageUrl, skimlinksId))
916-
case _ => url
917+
case _ => httpsUrl
917918
}
918919
}
919920

921+
def ensureHttps(url: String): String = url.replace("http:", "https:")
922+
920923
def isAffiliatable(element: Element): Boolean =
921924
element.tagName == "a" && SkimLinksCache.isSkimLink(element.attr("href"))
922925

923926
def linkToSkimLink(link: String, pageUrl: String, skimlinksId: String): String = {
924-
val urlEncodedLink = URLEncode(link)
927+
val urlEncodedLink = URLEncode(ensureHttps(link))
925928
s"https://go.skimresources.com/?id=$skimlinksId&url=$urlEncodedLink&sref=$host$pageUrl"
926929
}
927930

common/test/views/support/cleaner/AffiliateLinksCleanerTest.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ class AffiliateLinksCleanerTest extends AnyFlatSpec with Matchers {
1515
)
1616
}
1717

18+
"linkToSkimLink" should "replace http: with https: in the original link" in {
19+
val link = "http://www.piratendating.nl/"
20+
val pageUrl = "/guardian-pirates/soulmates"
21+
linkToSkimLink(link, pageUrl, "123") should equal(
22+
s"https://go.skimresources.com/?id=123&url=https%3A%2F%2Fwww.piratendating.nl%2F&sref=${Configuration.site.host}/guardian-pirates/soulmates",
23+
)
24+
}
25+
1826
"shouldAddAffiliateLinks" should "correctly determine when to add affiliate links" in {
1927
// the switch is respected
2028
shouldAddAffiliateLinks(

project/Dependencies.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ object Dependencies {
66
val identityLibVersion = "4.31"
77
val awsVersion = "1.12.791"
88
val awsSdk2Version = "2.35.10"
9-
val capiVersion = "37.1.0"
10-
val faciaVersion = "23.0.0"
9+
val capiVersion = "38.0.0"
10+
val faciaVersion = "24.0.0"
1111
val dispatchVersion = "0.13.1"
1212
val romeVersion = "1.0"
1313
val jerseyVersion = "1.19.4"
@@ -30,7 +30,7 @@ object Dependencies {
3030
val commonsIo = "commons-io" % "commons-io" % "2.16.1"
3131
val cssParser = "net.sourceforge.cssparser" % "cssparser" % "0.9.30"
3232
val contentApiClient = "com.gu" %% "content-api-client" % capiVersion
33-
val contentApiModelsJson = "com.gu" %% "content-api-models-json" % "31.0.0"
33+
val contentApiModelsJson = "com.gu" %% "content-api-models-json" % "32.0.0"
3434
val faciaFapiScalaClient = "com.gu" %% "fapi-client-play30" % faciaVersion
3535
val identityCookie = "com.gu.identity" %% "identity-cookie" % identityLibVersion
3636

0 commit comments

Comments
 (0)