Enginyeria inversa del ecommerce de Nike amb només el navegador

Article sobre les tecnologies que s'utilitzen a l'ecommerce de nike

post-image

El proposit d'aquest article es veure fins al punt que es pugui quines tecnologies utilitza i com les utiltiza un ecommerce "gran" com en aquest cas es el de nike i fer-ho utilitzant les developer tools del navegador.

Primer de tot si inspeccionem la pàgina podem veure que utilitza el framework Next.js,

<div id="experience-wrapper">
  <div id="__next">
    <div id="ciclp-app" class="">
      <div>
        <script type="application/ld+json" data-qa="WebPageScript">
          {
            "@context": "http://schema.org",
            "@type": "WebPage",
            "@id": "https://www.nike.com/#webpage",
            "url": "https://www.nike.com/",
            "name": "nike"
          }
        </script>
      </div>
    </div>
  </div>
</div>

On podem trobar el div amb l'id __next, això o que en uns dels primer showcases que podem veure a la propia web de nextjs.

Dins del la pestanya sources dels devtools del navegador podem veure que hi ha la "carpeta" webpack://, aquesta carpeta es una carpeta virtual que ens permet veure els fitxers originals, ja que tenen habilitada l'opció de source maps a la configuració de webpack (cosa una mica rara tot sigui dit).


També podem veure la carpeteta de webpack dins d'altres carpetes, com ara són les de _N_E, analytics-client, privacy-consent, privacy-Core i WebShellClient etc. Això ens facilitara la vida a l'hora de veure de que tractent ja que no haurem de tractar amb codi minificat ni uglificat.

També podem usar les react devtools per poder veure les props i els estats dels components que es renderitzen a la pàgina:

reat-devtocols

Si analitzem una mica podem veure que estan utilitzan Redux per la gestió de l'estat de l'aplicació, el cual es carrega al primer component de la pagina, aqui tenim un tutorial de com s'utilitza redux amb nextjs.

Al primer component de la pàgina també podem veure les rutes dins de nextjs, per exemple la home és:

isSsr:true
query: {
  "page": [
    "es",
    "ca"
  ]
}
route: "/[[...page]]"

En canvi a la url de les ofertes amb path /es/ca/w/ofertes-3yaep:

isSsr:true
query: {
    country: "es",
    language: "ca",
    slug: ["ofertes-3yaep"]
}
route:"/[country]/[language]/w/[[...slug]]"

O un producte amb path /es/ca/t/dri-fit-samarreta-de-fitnes-home-v75KxD/AR6029-063:

isSsr:true
query: {
    slug: "dri-fit-samarreta-de-fitnes-home-v75KxD",
    styleColor: "AR6029-063",
}
route:"/t/[slug]/[styleColor]"

Una de les coses que podem treure de les propietats que rep el primer component es el tipus de renderitzat que utiltizen amb el nextJs.

__N_SSP: true
__N_SSG: undefined

Podem veure que aquestes pagines es renderitzen amb server side rendering (SSR), i dins del server side rendering podem veure que no fan servir server side generation (SSG), si no que les pagines són generades a partir del getServerSideProp (SSP), això segurament es per la cantitat de productes que tenen i per mantenir les pagines mes actualitzades.

En canvi la pagina de perfil (/es/ca/member/profile/) si que utilitza el SSG, tot i que realment el contingut rellevant es carrega amb el client side rendering (CSR).

__N_SSG: true
route:"/[country]/[language]/member/profile"

També podem analitzar la cache de les diferentes pagines mirant el Cache-Control dels headers de resposta a la primera petició:

URLCache-Control
/es/ca/max-age=646
/es/ca/w/ofertes-3yaepmax-age=287
/es/ca/t/dri-fit-samarreta-de-fitnes-home-v75KxD/AR6029-063max-age=899

Del que podem veure que la home es refresca cada 10 minuts, les ofertes cada 5 minuts i els productes cada 15 minuts, pero el tema del caching millor el deixem per un futur article.

Si mirem les peticiones un dels headers que portla la resposta es el de Akamai-Grn, si busquem akami pel codi o directament a google podem veure que es tractara d'alguns serveis de l'empresa Akami com ara cyberseguretat, cloud services o CDN entre molts altres.

import { useRef } from 'react';
import { useQuery } from 'react-query';
import { fetchClient } from '@nike/fetch-client';
import { getExperimentDriver } from '@nike/ux-tread-optimizely';
import useOptimizelyEnabled from './use-optimizely-enabled';

export const PROD_OPTIMIZELY_URL = `https://${process.env.NEXT_PUBLIC_HOST_NAME}/assets/vendor/optimizely/prod/5aUCtZvFuDLjvdqdf2w53M.json`;
export const DEV_OPTIMIZELY_URL = `https://assets.commerce.nikecloud.com/vendor/optimizely/test/3srgubLSVD2VBXqgkJtCRh.json`; // NOTE - we need to use this for now until akamai is fixed
export const OPTIMIZELY_ERROR_MESSAGE = 'Error fetching optimizely datafile';

const useOptimizelyDataFile = () => {
  const optimizelyEnabled = useOptimizelyEnabled();
  const optimizelyClient = useRef();
  const isProd = process.env.NODE_ENV === 'production';
  const optimizelyUrl = isProd ? PROD_OPTIMIZELY_URL : DEV_OPTIMIZELY_URL;

  const { data } = useQuery(
    optimizelyUrl,
    () =>
      fetchClient(optimizelyUrl, { method: 'GET' }, OPTIMIZELY_ERROR_MESSAGE),
    { enabled: optimizelyEnabled }
  );

  if (data) {
    optimizelyClient.current = getExperimentDriver(data);
  }

  return { datafile: data, optimizelyClient: optimizelyClient };
};

export default useOptimizelyDataFile;

En aquest cas sembla que es tracta d'un servei de CDN.

Design system

Si indaguem una mica per respondre a la pregunta de com gestionen els estils per els components de react, veiem que en alguns dels components s'utilitza emotion, una llibreria css-in-js:

import styled from '@emotion/styled';

A part podem veure diferents paquets que fan referencia a un design system com ara el @nike/nike-design-system-components on podem veure que tenen diferents components:

  • Accordion
  • Buttons
  • Cards/ProductCard
  • Carousel
  • Details
  • Layout

Dins de layout hi han diferents components per gestionar el layout de la pàgina, com ara el AspectRation, Box, Grid, Stack, ...

També podem trobar quin son els espais que utilitzent:

export var getSpacing = function (t) {
  var n = 'var(--podium-cds-size-spacing-'.concat(t.toLowerCase(), ')');
  return ['xs', 's', 'm', 'l', 'xl', 'xxl', 'xxxl', 'xxxxl'].includes(t)
    ? n
    : t;
};

On els valors de les variables són:

--podium-cds-size-spacing-xs: 4px;
--podium-cds-size-spacing-s: 8px;
--podium-cds-size-spacing-m: 12px;
--podium-cds-size-spacing-l: 24px;
--podium-cds-size-spacing-xl: 36px;
--podium-cds-size-spacing-xxl: 60px;
--podium-cds-size-spacing-xxxl: 84px;
--podium-cds-size-spacing-xxxxl: 120px;
  • Loaders/Skeleton
  • NikeDesignSystemProvider
  • PullQuote
  • SelectionControls/Switch
  • SizeChart
  • Typography/Text

Aqui podem trobar un mapping depenent del tipus de text quin element de html es renderitzara, per temes de seo:

export var componentMapping = {
  body1: 'p',
  body2: 'p',
  body3: 'p',
  body1Strong: 'p',
  body2Strong: 'p',
  body3Strong: 'p',
  legal: 'p',
  editorialBody1: 'p',
  editorialBody1Strong: 'p',
  oversize1: 'p',
  oversize2: 'p',
  oversize3: 'p',
  display1: 'h1',
  display2: 'h2',
  display3: 'h3',
  display4: 'h4',
  title1: 'h1',
  title2: 'h2',
  title3: 'h3',
  title4: 'h4',
  conversation1: 'p',
  conversation2: 'p',
  conversation3: 'p',
  conversation4: 'p',
};

Podem fixar-nos que en alguns components fan us dels tokens importats de @nike/design-system-base, com podem veure al fitxer components/dialog/styles.js:

import styled from '@emotion/styled';
import { Modal } from '@nike/nr-sole-modal';
import tokens from '@nike/design-system-base';

export const StyledModal = styled(Modal)`
  @media only screen and (max-width: ${tokens.bp('tablet')}px) {
    transition: visibility 0s ease, bottom 0.5s ease;
  }
  & .modal-container {
    border-radius: 16px;
    padding: 24px;
    text-align: left;
    transform: translateY(50px);
    transition: opacity 0.6s ease 0.2s, transform 0.4s ease 0.2s,
      height 0.4s ease;
    padding: 12px;

    /*  desktop media query minus one for ipad pro */
    @media only screen and (max-width: ${tokens.bp('desktop') - 1}px) {
      &.modal-container {
        max-width: 100%;
        position: fixed;
        padding: 16px 0 0;
        bottom: 0px;
        border-radius: 0;
      }
    }
  }
`;

d'aqui podem extreure els tokens:

{
  "default": {
    "opts": {
      "fontSizeUnit": "px"
    },
    "ds": {
      "type": {
        "baseFontSize": "16px",
        "sizes": {
          "xs": "14px",
          "sm": "20px",
          "baseline": "16px",
          "md": "22px",
          "lg": "26px",
          "xl": "30px"
        },
        "size": {
          "brandMarketing": {
            "xs": "14px",
            "sm": "20px",
            "baseline": "16px",
            "md": "60px",
            "lg": "80px",
            "xl": "120px"
          },
          "desktop": {
            "xs": "14px",
            "sm": "20px",
            "baseline": "16px",
            "md": "24px",
            "lg": "28px",
            "xl": "36px"
          }
        },
        "fontFamily": {
          "base": "\"Helvetica Neue\", Helvetica, Arial, sans-serif",
          "brand": "\"Nike TG\", \"Helvetica Neue\", Helvetica, Arial, sans-serif",
          "marketing": "\"Nike Futura\", \"Helvetica Neue\", Helvetica, Arial, sans-serif"
        },
        "lineHeight": {
          "mobile": {
            "14": 1.7142857142857142,
            "16": 1.75,
            "20": 1.5,
            "22": 1.4545454545454546,
            "24": 1.5,
            "26": 1.3076923076923077,
            "28": 1.4285714285714286,
            "30": 1.3333333333333333,
            "36": 1.3333333333333333,
            "40": 0.9,
            "60": 0.8333333333333334,
            "80": 0.875,
            "120": 0.8333333333333334
          },
          "tablet": {
            "20": 1.6,
            "60": 0.9333333333333333
          }
        },
        "fontWeight": {
          "regular": 400,
          "medium": 500
        }
      },
      "colors": {
        "colorPalette": {
          "black": {
            "base": "#111111",
            "light": "rgba(0, 0, 0, 0.75)",
            "dark": "#0D0D0D"
          },
          "white": {
            "base": "#ffffff",
            "light": "rgba(255, 255, 255, 0.75)",
            "dark": "#BFBFBF"
          },
          "grey": {
            "base": "#757575",
            "light": "rgba(117, 117, 117, 0.75)",
            "dark": "#585858"
          },
          "orange": {
            "base": "#FA5400",
            "light": "rgba(250, 84, 0, 0.75)",
            "dark": "#BC3F00"
          },
          "red": {
            "base": "#d43f21"
          },
          "green": {
            "base": "#128a09"
          }
        },
        "brand": {
          "black": "#111111",
          "white": "#ffffff",
          "grey": "#757575",
          "orange": "#FA5400",
          "red": "#d43f21",
          "green": "#128a09",
          "inactiveGrey": "#CCCCCC",
          "borderGrey": "#E5E5E5",
          "primaryGrey": "#F5F5F5",
          "secondaryGrey": "#FAFAFA",
          "accent": "#FA5400",
          "error": "#d43f21",
          "success": "#128a09"
        }
      },
      "breakpoints": {
        "mobile": 0,
        "tablet": 600,
        "desktop": 960,
        "largeDesktop": 1440,
        "extraLargeDesktop": 1920
      },
      "mediaQueries": {
        "mobile": "only screen and (min-width: 0px)",
        "sm": "(max-width: 599px)",
        "md": "(min-width: 600px) and (max-width: 959px)",
        "lg": "(min-width: 960px) and (max-width: 1439px)",
        "xl": "(min-width: 1440px) and (max-width: 1919px)",
        "xxl": "(min-width: 1920px)",
        "tablet": "only screen and (min-width: 600px)",
        "desktop": "only screen and (min-width: 960px)",
        "largeDesktop": "only screen and (min-width: 1440px)",
        "extraLargeDesktop": "only screen and (min-width: 1920px)"
      },
      "zIndex": {
        "z0": 0,
        "z1": 1,
        "z2": 2,
        "z3": 3,
        "z4": 4,
        "z5": 5,
        "z6": 6,
        "z7": 7,
        "z8": 8,
        "z9": 9,
        "z10": 10,
        "low": 0,
        "mid": 5,
        "high": 10
      },
      "scaleIncrement": 4,
      "spacing": {
        "baseline": 16,
        "padding": "4px",
        "scale": [
          0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68,
          72, 76, 80, 84, 88, 92, 96, 100, 104, 108, 112, 116
        ]
      },
      "layout": {
        "gutter": 16,
        "maxWidth": 1808,
        "grid": {
          "columnCount": 12,
          "columnWidth": {
            "1": "8.333333333333334%",
            "2": "16.666666666666668%",
            "3": "25%",
            "4": "33.333333333333336%",
            "5": "41.66666666666667%",
            "6": "50%",
            "7": "58.333333333333336%",
            "8": "66.66666666666667%",
            "9": "75%",
            "10": "83.33333333333334%",
            "11": "91.66666666666667%",
            "12": "100%"
          }
        }
      },
      "transition": {
        "default": {
          "duration": "250ms",
          "timing": "cubic-bezier(0.77, 0, 0.175, 1)",
          "transition": "all 250ms cubic-bezier(0.77, 0, 0.175, 1)"
        }
      },
      "borderRadius": 24
    }
  }
}

Colors:

Internacionalització

Si mirem el network ja podem veure algunes peticions que porten el i18n al seu path, aquest és el cas de la pàgina de favorits (https://www.nike.com/favorites):

https://www.nike.com/assets/i18n/dotcom-fragments-recommendations/en-US.json

On la resposta es un json amb una key que porta un objecte amb les keys de "value" i "description":

{
  "favorites.productRecommendationsCarousel.title": {
    "value": "You Might Also Like",
    "description": "The title for the product recommendations carousel displayed on the favorites page"
  },
  "profile.favoritesCarousel.title": {
    "value": "Favorites",
    "description": "Title for the favorites section"
  },
  "profile.favoritesCarousel.viewAllLink": {
    "value": "View All",
    "description": "The link that takes users to the page where they can view all of their Favorites"
  },
  "recommendations.badges.member_access_label": {
    "value": "Member Access",
    "description": "This text is displayed in carousel card for member exclusive product"
  },
  "recommendations.badges.member_exclusive": {
    "value": "Get this product with your free Nike Membership Profile",
    "description": "This text is displayed in carousel card for member exclusive product"
  },
  "recommendations.badges.nike_by_you": {
    "value": "Nike by You",
    "description": "This text is displayed in carousel card for Nike by You product"
  },
  "recommendations.badges.nike_by_you_label": {
    "value": "Customize",
    "description": "This text is displayed in carousel card for Nike by You product"
  },
  "recommendations.favorites.title": {
    "value": "Find Your Next Favorite",
    "description": "Default Title of Recommendations"
  },
  "recommendations.next.label": {
    "value": "Next Recommended Product",
    "description": "Next Button Title"
  },
  "recommendations.nullsearch.title": {
    "value": "These popular items might interest you",
    "description": "Product wall null search title for recommendations carousel"
  },
  "recommendations.previous.label": {
    "value": "Previous Recommended Product",
    "description": "Previous Product Label for Button"
  },
  "recommendations.top_trending": {
    "value": "Top Trending",
    "description": "Title for a component which will display our top trending products for user's market place"
  }
}

webpack://Recommendations Carousel/src/constants/translations.ts

També podem veure com dins del script amb el tag <script id="__NEXT_DATA__" type="application/json"> podem trobar traduccions amb el mateix format:

{
  "props": {
    "pageProps": {
      "locale": {
        "urlParam": "en",
        "language": "en",
        "intl": "en-US",
        "langRegion": "en-US",
        "hreflang": "en-US",
        "default": true,
        "country": "us",
        "currency": "USD",
        "countryName": "United States",
        "countryNames": {
          "en": "United States"
        },
        "currencySymbol": "$",
        "merchGroup": "US",
        "cloudUrlFragment": "",
        "translationsLanguage": "en-US"
      },
      "translations": {
        "91330387": {
          "value": "Name of wishlist is not unique.",
          "description": "/buy/list_items/v1/ NOT_UNIQUE - Name is not unique."
        },
        "99668031": {
          "value": "No locations are defined for an item.",
          "description": "/buy/cart_reviews/v2/NO_AVAILABLE_LOCATIONS_FOUND - No locations are defined for an item"
        },
        "accessibility.skipToContent": {
          "value": "Skip to content. (Press Enter)",
          "description": "When a blind user is on this button, it allows them to skip passed the menu and into the main content area."
        },
        "cart.emptyMsg": {
          "value": "There are no items in your bag.",
          "description": "Empty Message for Cart"
        },
        "cart.emptyMsg.cta": {
          "value": "Continue Shopping",
          "description": "CTA for empty Cart"
        },
        "cartSummary.learnMore": {
          "value": "The actual tax will be calculated based on the applicable state and local sales taxes when your order is shipped. \u003ca href=\"{learnMoreLinkUrl}\"\u003eLearn More\u003c/a\u003e",
          "description": "Cart summary learn more"
        },
        "cta.add": {
          "value": "Add",
          "description": "Add warranty"
        },
        "cta.addAGiftBag": {
          "value": "Add a Gift Bag",
          "description": "Add Gift Bag"
        }
      }
    }
  }
}

Aquesta arquitectura fa que tinguin valors repetits com pot ser el done de les cookies i el done de la wishlist:

"hf_cookie_label_done": {
    "value": "Done",
    "description": "Done"
},
...
"wishlist.done": {
    "value": "Done",
    "description": "Wishlist done editing"
},

I sembla ser que la descripció no es fa servir a la web, simplement es per donar context del value.

Analítiques

Un dels events que podem veure que s'estan enviant es el de optimizely, que com ells mateixos es descriuen en un sistema "all-in-one" per marketing.

Aquí podem veure el payload que s'envia a la petició:

https://logx.optimizely.com/v1/events

Payload:

{
  "client_name": "javascript-sdk",
  "client_version": "3.5.0",
  "account_id": "6194110778",
  "project_id": "9011010262",
  "revision": "13131",
  "anonymize_ip": true,
  "enrich_decisions": true,
  "visitors": [
    {
      "snapshots": [
        {
          "decisions": [
            {
              "campaign_id": "24062810741",
              "experiment_id": "24082260985",
              "variation_id": "24099740550"
            }
          ],
          "events": [
            {
              "entity_id": "24062810741",
              "timestamp": 1704405218882,
              "key": "campaign_activated",
              "uuid": "5cf80aa9-814c-4cfa-b0fd-f30c82320855"
            }
          ]
        }
      ],
      "visitor_id": "3519256F78D0F81D6DBE84407F32C100",
      "attributes": [
        {
          "entity_id": "10519940845",
          "key": "country",
          "type": "custom",
          "value": "us"
        },
        {
          "entity_id": "$opt_bot_filtering",
          "key": "$opt_bot_filtering",
          "type": "custom",
          "value": true
        }
      ]
    },
    {
      "snapshots": [
        {
          "decisions": [
            {
              "campaign_id": "24553700035",
              "experiment_id": "24547710456",
              "variation_id": "24557080326"
            }
          ],
          "events": [
            {
              "entity_id": "24553700035",
              "timestamp": 1704405218883,
              "key": "campaign_activated",
              "uuid": "d8531f89-ed25-4e2e-80be-f5d5bcdc0190"
            }
          ]
        }
      ],
      "visitor_id": "3519256F78D0F81D6DBE84407F32C100",
      "attributes": [
        {
          "entity_id": "10519940845",
          "key": "country",
          "type": "custom",
          "value": "us"
        },
        {
          "entity_id": "$opt_bot_filtering",
          "key": "$opt_bot_filtering",
          "type": "custom",
          "value": true
        }
      ]
    }
  ]
}

Un dels serveis que ofereix es el de crear "experiments" (com es veu a la petició) de tal forma que podem mostrar experiencies d'usuari personalitzades i validar si aquestes ajuden o no a fer més vendes (es a dir proves A/B i proves multivariants), a part es pot integrar amb google analytics entre altres.

Una de les altres peticions que podem veure que envia events es la seguent:

https://analytics.nike.com/v1/t

Payload:

{
  "anonymousId": "3519256F78D0F81D6DBE84407F32C100",
  "event": "Impression Tracked",
  "permissions": [
    {
      "category": "adtargeting_behavioralevents",
      "consent": "accept"
    },
    {
      "category": "personalization_targeted_comms",
      "consent": "accept"
    },
    {
      "category": "adtargeting_hashedmatch",
      "consent": "default_decline"
    },
    {
      "category": "performance",
      "consent": "accept"
    }
  ],
  "type": "track",
  "writeKey": "xxxxxx",
  "properties": {
    "abTest": {
      "adobeTargetId": "xxxxxx"
    },
    "classification": "experience event",
    "consumer": {
      "adobeMarketingCloudId": "xxxxx",
      "visitorId": "xxxx",
      "anonymousId": "xxxx",
      "isSwoosh": false,
      "loginStatus": "logged in",
      "upmId": "xxxx"
    },
    "content": {
      "landmarkX": 50,
      "landmarkY": 80,
      "timestamp": "1704405322422",
      "assetId": "xxxxxx",
      "cardKey": "xxxxxx",
      "componentType": "c_c_i",
      "placementId": 1,
      "positionId": 7,
      "threadId": "xxxxx",
      "threadKey": "xxxxx",
      "totalPositions": 7
    },

    "country": "ES",
    "eventName": "Impression Tracked",
    "eventType": "track",
    "language": "en-US",
    "previousView": {
      "pageName": "nikecom>homepage",
      "pageType": "homepage"
    },
    "wrapper": {
      "build": "production",
      "version": "3.23.0"
    },
    "app": {
      "backendPlatform": "cloud",
      "version": "a9ac8fa5d",
      "domain": "www.nike.com"
    },
    "view": {
      "experienceType": "nikecom",
      "pageName": "nikecom>homepage",
      "pageType": "homepage"
    },
    "locale": {
      "language": "en",
      "country": "us"
    }
  },
  "context": {
    "direct": true,
    "library": {
      "name": "@nike/analytics-client",
      "version": "3.23.0"
    },
    "page": {
      "path": "/",
      "title": "Nike. Just Do It. Nike.com",
      "url": "https://www.nike.com/"
    },
    "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
  },
  "sentAt": "2024-01-04T21:55:22.440Z",
  "$schema": "https://www.nike.com/assets/measure/schemas/digital-product/dotcom/platform/web/classification/experience-event/experience/landing-page/event-type/track/event-name/impression-tracked/version/LATEST/schema.json",
  "messageId": "xxxxx",
  "timestamp": "2024-01-04T21:55:22.439Z"
}

En aquest cas podem veure que el domini es el mateix de nike (analytics.nike.com) però com podem veure hi han algunes referencies a adobe target (adobeTargetId, adobeMarketingCloudId)
això és perquè els events i les proves A/B a part del que servei que hem vist a l'anterior petició majoritàriament sembla que utilitzen el servei de adobe target, com també podem veure a la següent petició:

https://abt.nike.com/rest/v1/delivery?client=nike&sessionId=xxxx&version=2.8.1
{
  "requestId": "fa94556776cb469d85ddd34ca5b5b6c0",
  "context": {
    "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
    "timeOffsetInMinutes": 60,
    "channel": "web",
    "screen": {
      "width": 3440,
      "height": 1440,
      "orientation": "landscape",
      "colorDepth": 24,
      "pixelRatio": 1
    },
    "window": {
      "width": 2310,
      "height": 1277
    },
    "browser": {
      "host": "www.nike.com",
      "webGLRenderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002484) Direct3D11 vs_5_0 ps_5_0, D3D11)"
    },
    "address": {
      "url": "https://www.nike.com/",
      "referringUrl": ""
    }
  },
  "id": {
    "tntId": "xxxx.37_0"
  },
  "experienceCloud": {
    "analytics": {
      "logging": "server_side"
    }
  },
  "prefetch": {
    "views": [{}]
  }
}

response:

{
  "status": 200,
  "requestId": "fa94556776cb469d85ddd34ca5b5b6c0",
  "client": "nike",
  "id": {
    "tntId": "65e3db1043a54d18b03e1388bdf05f09.37_0"
  },
  "edgeHost": "mboxedge37.tt.omtrdc.net",
  "prefetch": {},
  "telemetryServerToken": "cJWuCgDiaU1IgPqDGhBbFJeqzwYSwsSIodTTUorUU84="
}

On el que podem deduir que abt.nike es refereix a Adobe Target, el cual al igual que el servei d'optimizely permet fer proves A/B i multivariants.

En aquest cas podem veure com ho tenen integrat en el seu propi codi:

nike/analytics-client/src/analytics-event/abtest.ts
import { CacheSelector } from '../cache';
const getABTest = (_rawEvent, cache) => {
    const adobeTargetId = cache.get(CacheSelector.MBOX);
    return {
        adobeTargetId: typeof adobeTargetId === 'string' ? adobeTargetId : undefined,
    };
};
export { getABTest };

Feature Flags

Petició a: https://www.nike.com/assets/experience/cart/static/feature-flags.json

{
    "bopisEnabled": [],
    "bopisExperimentEnabled": [],
    "digitalGiftCardsEnabled": [],
    "eddEnabled": [],
    "estimatedTaxEnabled": [],
    "estimatedTaxTooltipEnabled": [],
    "foffEnabled": [],
    "giftBagEnabled": [],
    "giftMessageEnabled": [],
    "marketingMessageEnabled": [],
    "membershipBannerEnabled": [],
    "membershipLinksEnabled": [],
    "multiCurrencyPricingEnabled": [],
    "physicalGiftCardsEnabled": [],
    "promotionCodesEnabled": [],
    "recommendationsEnabled": [],
    "subtotalTooltipEnabled": [],
    "wishlistEnabled": [
        "ae",
        "at",
        "au",
        ...
    ]
}

També podem trobar un extens llistat amb els feature flags que tenim activats i quins no:

abTests:true
animateAccordionPanels:false
cookieSettings:true
cookieSettingsBanner:false
cutOverPdpToSegment:true
debug:false
enableChat:false
enableMAP:false
enableNikeShopModals:true
filterByNikeDotComChannel:true
globalNavUseGeoPrivacy:true
gridRecommendations:true
hideBuyingTools:false
hideEDDFasterShippingOptionMsg:false
incentivizedReviewsMessage:true
incentivizedReviewsMessageNA:false
includeFacebookAppId:false
isZoomEnabled:false
loadForterScript:true
loadStylitics:true
loadWebShell:true
loadWebShellOnWall:false
logoutShouldClearCart:true
lowInventoryMessage:true
notifyMe:true
recommendations:true
reserveForYou:true
sendPIDsOnWall:true
shareNBY:true
showAvailabilityDate:false
showCNNewYearsLink:false
showCgc:true
showColorWayPicker:true
showEDDAndBopisABTesting:false
showEGift:false
showElevatedCarousel:true
showElevatedContent:true
showEstimatedDeliveryDate:false
showEstimatedDeliveryDateAndPickup:false
showFeedsPromoExclusionMessage:true
showFit:false
showGeoMismatch:true
showGiftCardMessage:true
showGiftCardTypeLink:false
showGiftCards:true
showJerseyId:true
showKlarnaPayLaterMessage:false
showKlarnaPayMessage:false
showLargeGiftCardOrder:false
showLaunchDebug:false
showLaunchProduct:true
showLeftHandNavCategories:true
showMobileWriteReview:true
showMoreInfo:true
showNBY:true
showNBYMessageHub:true
showNikeId:true
showOmni:false
showPhysicalGiftCards:true
showPickupLocation:true
showPromoBanner:true
showPromoExclusionMsg:false
showPromoMsg:true
showReadMore:true
showRelatedCategories:true
showReviews:true
showSEOCopy:true
showSinglesDaySalesLink:false
showSizeAndFit:true
showSizeCalculator:false
showSocialLinksNBY:true
showUniteTimers:false
showWidthSelector:true
showWishlist:true
skipPercentOff:false
skipSegmentDupedEvents:true
skipSustainability:false
swooshEligibleGeo:false
useBounceExchange:false
useDotcomNav:true
useDotcomNavESI:true
useExclusiveAccess:true
useFeedsMoreInfoMessage:true
useFeedsSizeFitMessage:true
useFeedsSustainabilityMessage:true
useMAPCollection:true
useNBYCloudUrl:true
useNBYImageService:true
useNeo:true
useNewRelic:true
useNotifyMeV2:false
useOfferRFY:false
useOptimizelyX:false
usePersonalizedNullSearch:false
useRollupKey:true
useSentryIO:false
useServiceCanonicalUrl:true
useSwitchboardRecommendations:false
useTTReviews:true
useTTV5Reviews:true
useUniteStaging:false
wallFavorites:false
wallFiltersBtnFilterLabel:false
wallImpressions:true
wallNoResultsCarousel:true

Observabilitat

Si mirem la consola del navegador podem veure que nike esta utiltizant el servi de newrelic per capturar expecions i errors del client:

_N_E/[[...page]].js
const isAdobeTargetEnabled = process.env.ADOBE_TARGET_ENABLED === 'true';

export const getServerSideProps = wrapper.getServerSideProps(
  store =>
    async ({ req, query, res }) => {
      const lpContext = {
        store,
        query,
      };

      queryParamsSanitizerMiddleware(req);

      // error handling should be initiated before the middlewares called as they can dispatch error actions
      store.dispatch(actions.logActions.init({ logger: req.logger, newrelic }));
      // call our old express middlewares
      featureFlagsMiddleware(req, lpContext);

      lpContext.fetchFn = getExtendedFetchFn(store.getState());
      // TODO: CICLP-3564 Required for data fetching in server side sagas. To be removed during cleanup
      globalThis.extendedFetch = lpContext.fetchFn;

Graphql

Una de les preguntes que em feia era si feien servir graphql o tot eran api custom/restful, tot i que clarament dominen més les apis rest, si que utiltizen graphql, en aquest cas especificament pel tema de les wishlists a l'hora de recuperar els items per exemple.

import graphqlFetch from '@nike/graphql-fetch';
import { fetchClient } from '@nike/fetch-client';
import { DOTCOM_UX_ID } from '@nike/ux-tread-dotcom-constants';

const graphqlClient = (args) =>
  graphqlFetch({
    fetchClient,
    url: process.env.GRAND_URL || 'https://api.nike.com/cic/grand/v1/graphql',
    callerId: window.NikeShop?.getVersion(),
    appId: DOTCOM_UX_ID,
    ...args,
  });

export default graphqlClient;

A part d'això no n'he pogut extreure gaire res més ja que fent la tipica petició per recuperar informació sobre el schema no retorna res (429 too many request).

react-devtools

Conclusions

  • Framework: Utilitzen Next.js, com es pot veure pel div amb l'id __next.
  • State Management: Redux
  • CDN: Fan servir Akamai
  • Sistema de Disseny: Utilitzen Emotion (CSS-in-JS) i un sistema de disseny prop
  • Internacionalització: Gestionen les traduccions i el i18n a través de fitxers JSON específics per idioma.
  • Analítiques: Empren serveis com Optimizely i Adobe Target per a l'anàlisi i la segmentació dels usuaris.
  • Feature Flags: Utilitzen un extens llistat de feature flags per gestionar funcionalitats específiques de la web.
  • Observabilitat: Emprant New Relic per a la monitorització i l'anàlisi d'errors.

Has trobat una errada? Edita-la al Github

T'ha agradat el post? Si us plau, comparteix-lo

Altres blogs que podrien interessar-te...

Què són els dark patterns?

Descobreix tot sobre els 'dark patterns' en aquest article, on s'explica en profunditat què són, com funcionen, els diferents tipus, per què s'utilitzen i com evitar-los.

twitter-profile
19 de març del 2024
Web Scraping d'ofertes de feina a LinkedIn utilitzant Puppeteer i RxJS

Tutorial sobre com extreure ofertes de feina de LinkedIn utilitzant Puppeteer i RxJS

twitter-profile
17 d’oct. del 2023