journal - front-end, ux/ui and branding
Crafting Pinfluencer, an influencer-marketing platform.
By Xavier Mod on 3rd December, 2022 · 5 min read
I worked on this product while working for Pinfluencer, and therefore I can't publicly share its full repository. For more information, please contact me.
Technologies used: Vue, React, SCSS, Redux, Axios, Gatsby, AWS, Cognito, Sketch, Adobe XD
Pinfluencer is an influencer marketing platform that connects sustainably-minded brands with influencers in order to facilitate collaborations that have a net positive impact. During my third year of University, I joined the Pinfluencer team as a Front-End Developer / Designer, being also one of the earliest team members. Always as a side-project, I helped developing Pinfluencer's visual interfaces for several product iterations - finally developing a Proof Of Concept which connected influencers with brands.
Overview of the project
I worked on three different projects during my time at Pinfluencer: Pinfluencer Portal, Pinfluencer.io and Pinfluencer Brands.
Pinfluencer Portal
Pinfluencer was the first product iteration, which I re-designed from the ground up and implemented using Vue.js, SASS and some Bootstrap elements. My main job was to hook up the interface to a Symfony (PHP) API developed by the back end team. The project did not end up being released as it was part of the first product iterations.

Portal was a Vue 2 + Symfony monorepo. I was part of the Front-End team, and became the sole front-end developer after a few weeks. I was in charge of the front-end planning and architecture, being able to freely choose the direction the product was going to take. In order to create a POC for a Dashboard-like profile, I worked with several packages:
json1{2 "devDependencies": {3 "@fortawesome/fontawesome-free": "^5.13.0",4 "@symfony/webpack-encore": "^0.28.3",5 "bootstrap": "^4.4.1",6 "core-js": "^3.0.0",7 "node-sass": "^4.13.1",8 "popper.js": "^1.16.1",9 "regenerator-runtime": "^0.13.2",10 "sass-loader": "^7.0.1",11 "vue-concise-slider": "^3.4.4",12 "webpack-notifier": "^1.6.0"13 },14 "dependencies": {15 "@popperjs/core": "^2.2.0",16 "axios": "^0.19.2",17 "bootstrap-vue": "^2.15.0",18 "chart.js": "^2.9.3",19 "jquery": "^3.4.1",20 "vue": "^2.6.11",21 "vue-chartjs": "^3.5.0",22 "vue-content-loader": "^0.2.3",23 "vue-infinite-loading": "^2.4.5",24 "vue-infinite-scroll": "^2.0.2",25 "vue-loader": "^15.9.2",26 "vue-router": "^3.4.5",27 "vue-scroll-loader": "^2.2.0",28 "vue-simple-suggest": "^1.10.1",29 "vue-slider-component": "^3.2.2",30 "vue-styled-components": "^1.5.1",31 "vue-template-compiler": "^2.6.11",32 "vuelidate": "^0.7.5",33 "vueperslides": "^2.10.2"34 }35}
I primarily focused on the user-facing interface, built with Vue.js. The POC was going to be used by different brands which they could use to manage and upload different campaigns with products.
Main Overview
The main overview screen displayed several stats from the API, in a very simple and clear manner. To communicate with the back-end, I decided to use Axios.

Influencer Search
Brands needed to be able to filter through different influencers, in order to make the best choice when doing collaborations. An advanced search was implemented with several filtering categories, ranging from influencer size, niche, countries, etc.
vue1<!-- Example of a component using vue-slider-component, which allows the user to select a range of different categories. -->2<template>3 <div4 class="light-padded-border white-bg"5 :style="{6 width: `${width}px`,7 }"8 >9 <div class="row">10 <small class="col-2 text-left"11 >{{ min }} <span v-if="minLabel">{{ minLabel }}</span></small12 >13 <h6 class="col-8 text-center">{{ headingName }}</h6>14 <small class="col-2 text-right"15 >{{ max }} <span v-if="maxLabel">{{ maxLabel }}</span></small16 >17 </div>1819 <vue-slider20 v-model="range"21 :enable-cross="false"22 :min="min"23 :max="max"24 :interval="interval"25 hide-label26 @change="$emit('change', range)"27 />28 </div>29</template>

Creating and managing campaigns
Portal's first goal was to allow brands to manage several campaigns, which could include several influencers. These campaigns, which were being loaded from the API, also included skeleton loaders to make improve the overall user experience. The creation of campaigns had three parts: Selecting the basic information of a campaign, which included text input and selection data points.
vue1<!-- Example of an input component, which is an extension of the basic b-form-input and includes several features such as warning modes, max-lengths and a character counter -->2<template>3 <div :class="`PInput ${warningMode ? 'warning' : null}`">4 <b-container fluid>5 <label class="label" :for="`type-${type}`">{{ label }}</label>6 <div class="PInput__standard">7 <b-form-input8 v-if="type !== 'textarea'"9 b-form-input10 :id="`type-${type}`"11 :name="name"12 @change="el => sendData(el, type)"13 :maxlength="maxLength"14 @keyup="el => charCount(el)"15 :placeholder="placeholder"16 :type="type"17 ></b-form-input>18 <span v-if="addCounter" class="PInput__counter"19 >{{ totalcharacter }}/{{ maxLength }}</span20 >21 </div>22 <div v-if="type == 'textarea'" class="PInput__textarea">23 <textarea24 class="form-control"25 id="exampleFormControlTextarea1"26 :maxlength="maxLength"27 :name="name"28 @change="el => sendData(el, type)"29 :placeholder="placeholder"30 @keyup="el => charCount(el)"31 rows="3"32 ></textarea>33 <span class="PInput__counter"34 >{{ totalcharacter }}/{{ maxLength }}</span35 >36 </div>37 </b-container>38 <span v-show="warningMode" class="PInput__warningModeText"39 ><i style="margin-right: 5px" class="fas fa-exclamation-triangle"></i40 >{{ warningModeText }}</span41 >42 </div>43</template>

The second part of the campaign creation allowed users to upload several images to the campaign. A custom component was developed which also included an image counter and image previews.
vue1<!-- Custom image uploader component -->2<template>3 <div class="wrapper">4 <label>{{ label }}</label>5 <span @click="resetImages"6 ><i style="margin-right: 5px" class="fas fa-sync-alt"></i>reset7 images</span8 >9 <div class="PImageUploader">10 <div11 class="PImageUploader__image"12 v-for="(item, index) in items"13 :key="index"14 >15 <div16 v-if="!item.image"17 @click="launchFilePicker"18 class="19 image-placeholder20 d-flex21 justify-content-center22 align-items-center23 "24 >25 <h1 class="mb-0 p-red">+</h1>26 </div>27 <div v-else>28 <div class="image">29 <img :src="item.image" />30 </div>31 </div>3233 <input34 ref="file"35 type="file"36 style="display: none"37 @change="onFileChange(item, $event)"38 />39 </div>40 </div>41 <PCounter42 :count="uploadedImagesCount"43 :limit="5"44 :ok="uploadedImagesCount < 5"45 :breach="uploadedImagesCount === 5"46 />47 </div>48</template>4950<script>51export default {52 data() {53 return {54 items: [55 {56 image: false,57 },58 ],59 files: [],60 }61 },62 props: ["label"],63 computed: {64 imageCounterTextClass() {65 if (this.items.length === 5) return "text-danger"6667 return ""68 },69 imageCounterText() {70 return `${this.items.filter(item => item.image).length} / 5`71 },72 uploadedImagesCount() {73 return this.items.filter(item => item.image).length74 },75 },76 methods: {77 launchFilePicker() {78 this.$refs.file[0].click()79 },80 onFileChange(item, e) {81 const files = e.target.files || e.dataTransfer.files82 if (!files.length) return83 this.createImage(item, files[0])84 },85 createImage(item, file) {86 const image = new Image()87 const reader = new FileReader()8889 const index = this.items.findIndex(item => !item.image)9091 reader.onload = e => {92 this.items[index].image = e.target.result93 }94 reader.readAsDataURL(file)9596 this.files.push({ image: file })9798 this.$emit("image-uploaded", this.files)99 if (this.items.length < 5) {100 this.items.push({ image: false })101 }102 },103 resetImages() {104 this.items = [{ image: false }]105 this.files = []106 },107 removeImage: function (item) {108 item.image = false109 },110 },111}112</script>113114<style lang="scss" scoped>115@import "./assets/css/main.scss";116117label {118 font-weight: 700;119}120.wrapper {121 position: relative;122123 span {124 position: absolute;125 right: 0;126 background: #ff797a;127 color: white;128 cursor: pointer;129 padding: 5px;130 border-radius: 5px;131 font-size: 11px !important;132 }133}134135.PImageUploader {136 display: flex;137 align-items: center;138 justify-content: flex-start;139140 &__image {141 display: inline-block;142 margin-right: 10px !important;143144 .image-placeholder {145 height: 140px;146 width: 140px;147 background-color: white;148 border: solid 2px lightgray;149 border-radius: 10px;150 cursor: pointer;151 }152153 .image,154 .image > img {155 height: 140px;156 width: 140px;157 object-fit: cover;158 border-radius: 10px;159 }160 }161}162</style>
Besides basic text input and image loading, another custom list component was developed from the ground up, which allowed the user to add different Do's and Dont's for a particular campaign.
vue1<!-- Custom list input component -->2<template>3 <div class="PDosDonts">4 <div class="PDosDonts__Block">5 <h3>Do's</h3>6 <div class="PDosDonts__content">7 <ul class="list-unstyled">8 <li v-if="dos.length == 0" class="PDosDonts__noItems">9 Add your first item!10 </li>11 <li :key="item" v-for="(item, ind) in dos">12 <label>13 <p14 class="PDosDonts__closeIcon"15 @click="() => removeItem(ind, 'dos')"16 >17 <span>x</span>18 </p>19 <span v-bind:class="{ done: item.done }">{{ item.text }}</span>20 </label>21 </li>22 </ul>23 <p>24 <input25 class="PDosDonts__input"26 type="text"27 v-model="doText"28 placeholder="+ add new todo here"29 />30 <a v-on:click="addDo()" class="btn btn-primary btn-sm">+</a>31 </p>32 </div>33 </div>34 <div class="PDosDonts__Block">35 <h3>Dont's</h3>36 <div class="PDosDonts__content">37 <ul class="list-unstyled">38 <li v-if="donts.length == 0" class="PDosDonts__noItems">39 Add your first item!40 </li>41 <li :key="item" v-for="item in donts">42 <label>43 <p44 class="PDosDonts__closeIcon"45 @click="() => removeItem(ind, 'donts')"46 >47 <span>x</span>48 </p>49 <span v-bind:class="{ done: item.done }">{{ item.text }}</span>50 </label>51 </li>52 </ul>53 <p>54 <input55 class="PDosDonts__input"56 type="text"57 v-model="dontText"58 placeholder="add new todo here"59 />60 <a v-on:click="addDont()" class="btn btn-primary btn-sm">+</a>61 </p>62 </div>63 </div>64 </div>65</template>6667<script>68export default {69 data() {70 return {71 doText: "",72 dontText: "",73 dos: [],74 donts: [],75 }76 },77 methods: {78 addDo: function () {79 var newDo = this.doText.trim()80 if (!newDo) {81 return82 }83 this.dos.push({ text: newDo, done: false })84 this.doText = ""85 this.$emit("getDosDonts", { dos: this.dos, donts: this.donts })86 },87 addDont: function () {88 var newDont = this.dontText.trim()89 if (!newDont) {90 return91 }92 this.donts.push({ text: newDont, done: false })93 this.dontText = ""94 this.$emit("getDosDonts", { dos: this.dos, donts: this.donts })95 },96 removeItem: function (el, type) {97 if (type == "dos") {98 this.dos.splice(el, 1)99 } else if (type == "donts") {100 this.donts.splice(el, 1)101 }102103 this.$emit("getDosDonts", { dos: this.dos, donts: this.donts })104 },105 },106}107</script>
All campaign-related features were part of a global
<form />
component, which included validation throughout the campaign creation process to make sure the user wasn't missing any key data. Campaigns could be viewed in two different ways: through unstructured visual blocks and as a table. Using HTML's base <table />
component, a more tailored vue table component was created which included internal in-app links, status banners, date ranges and dynamic reports.
vue1<template>2 <div>3 <table class="table-1">4 <tr>5 <th v-for="(header, ind) in headers" :key="ind">6 {{ header }}7 </th>8 <th>Actions</th>9 </tr>10 <tr v-for="(el, ind) in data" :key="ind">11 <td v-if="el.template" class="template">12 <a :href="el.template.link" target="_blank">13 <i14 class="fa fa-circle"15 style="color: #dddddd"16 aria-hidden="true"17 ></i>18 <span>{{ el.template.title }}</span>19 <i20 style="opacity: 0.2; margin-left: 10px"21 class="fas fa-external-link-alt"22 ></i>23 </a>24 </td>25 <td v-if="el.influencer" class="influencer">26 <a :href="el.influencer.link" target="_blank">27 <img src="https://picsum.photos/200/300" />28 <span29 >{{ el.influencer.name.first }}30 {{ el.influencer.name.last }}</span31 >32 <i33 style="opacity: 0.2; margin-left: 10px"34 class="fas fa-external-link-alt"35 ></i>36 </a>37 </td>38 <td v-if="el.process" class="process">39 <span :class="`status-tag ${el.process.id}`">40 <i41 v-if="el.process.id == 'complete'"42 class="fas fa-check-circle"43 ></i>44 {{ el.process.displayName }}45 </span>46 </td>4748 <td v-if="el.size" class="size">49 <i style="margin-right: 10px" class="fas fa-users"></i>50 <span>{{ el.size.count }}</span>51 </td>5253 <td v-if="el.deliverables" class="deliverables">54 <span v-for="(deliverable, ind) in el.deliverables" :key="ind">55 {{ deliverable.amount }} x56 {{ deliverable.displayName }}57 </span>58 </td>5960 <td v-if="el.dateRange" class="date-range">61 <span>62 {{ el.dateRange.start.substring(0, 10) }} -63 {{ el.dateRange.end.substring(0, 10) }}64 </span>65 </td>6667 <td v-if="el.budget" class="budget">68 <span>69 {{ el.budget }}70 </span>71 </td>7273 <td>74 <div class="actions">75 <a76 v-if="el.process.id !== 'complete'"77 class="PTable__button"78 :href="`/collaboration/${el.id}`"79 target="_blank"80 >81 <i class="fas fa-eye"></i>82 </a>83 <a84 v-if="el.process.id == 'complete'"85 :href="`/collaboration/${el.id}/report/`"86 target="_blank"87 >88 <PButton body="View Report" type="btn-primary" />89 </a>90 </div>91 </td>92 </tr>93 </table>94 <div class="PTable__empty" v-show="isTableEmpty">No results</div>95 </div>96</template>

User Profiles
Both brands and influencers needed to have their own dynamically-created and editable profiles, which would be rendered through URL id's. Profiles included descriptions, lists, images, addresses, videos, external links such as Instagram videos, etc.

Pinfluencer Brands
Pinfluencer Brands was another iteration of the previous POC, which stripped out most of the complex features and only focused on the brand-influencer relationship. Futhermore, a different tech stack was used to develop it, this time going for React in the front-end with AWS Amplify being used in the back-end. As with Portal, I was fully in-charge of designing and developing the client interface.

This setup improved many aspects of the previous workflow, such as adding React Redux for state management, user authentication with AWS and JWTs, styled-components for CSS styling, among other tools and frameworks:
json1"dependencies": {2 "@aws-amplify/ui-react": "^1.2.17",3 "@reduxjs/toolkit": "^1.6.1",4 "@testing-library/jest-dom": "^5.11.4",5 "@testing-library/react": "^11.1.0",6 "@testing-library/user-event": "^12.1.10",7 "amazon-cognito-identity-js": "^5.1.1",8 "aws-amplify": "^4.2.11",9 "aws-amplify-react": "^5.1.0",10 "axios": "^0.21.4",11 "babel-eslint": "^10.1.0",12 "eslint-config-airbnb": "^18.2.1",13 "eslint-config-prettier": "^8.3.0",14 "eslint-plugin-import": "^2.25.2",15 "eslint-plugin-jsx-a11y": "^6.4.1",16 "eslint-plugin-prettier": "^4.0.0",17 "eslint-plugin-react": "^7.25.3",18 "formik": "^2.2.9",19 "jwt-decode": "^3.1.2",20 "material-design-icons": "^3.0.1",21 "react": "^17.0.2",22 "react-breadcrumbs-dynamic": "^1.2.1",23 "react-content-loader": "^6.0.3",24 "react-dom": "^17.0.2",25 "react-icons": "^4.2.0",26 "react-images-upload": "^1.2.8",27 "react-redux": "^7.2.5",28 "react-router": "^5.2.1",29 "react-router-dom": "^5.3.0",30 "react-scripts": "4.0.3",31 "react-through": "^1.1.4",32 "react-toastify": "^8.0.3",33 "simple-icons": "^5.16.0",34 "styled-components": "^5.3.1",35 "uuid": "^8.3.2",36 "web-vitals": "^1.0.1",37 "yup": "^0.32.9"38 },
Authentication was handled via AWS Cognito, through a JWT token that was then stored in the main state through Redux's toolkit. Token decodification was necessary to validate if the user had completed their profile, and to get several data points required to run the app.
js1// Main auth reducer used to authenticate the user when accessing the app.23import jwt_decode from "jwt-decode"4import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"5import { getBrandInfo } from "../../services/resources"67export const getTokenFromStorage = createAsyncThunk(8 "getTokenFromStorage",9 async () => {10 const token = await localStorage.getItem("@pinfluencer-auth")11 const hasCompletedProfile = await getBrandInfo()12 const decodedToken = jwt_decode(token)1314 // If the token has an email, then decode it and include it in the response.15 if (token) {16 return { token, hasCompletedProfile, decodedEmail: decodedToken?.email }17 }18 return { token, hasCompletedProfile }19 }20)2122export const authenticationSlice = createSlice({23 name: "authentication",24 initialState: {25 // User token26 token: null,27 // Returns true if user is authenticated28 isUserAuthenticated: null,29 // Returns true if user has a brand30 hasCompletedProfile: true,31 // Returns the user's brand32 brand: null,33 // If token includes an email, store it in state34 decodedEmail: null,35 // Returns brand image36 brandImage: null,37 },38 reducers: {39 logOut: state => {40 state.token = null41 state.isUserAuthenticated = false42 localStorage.removeItem("@pinfluencer-auth")43 },44 setCompletedProfile: (state, { payload }) => {45 state.hasCompletedProfile = payload46 },47 setImage: (state, { payload }) => {48 state.brandImage = payload49 console.log("changing from reducer", payload)50 },51 },52 extraReducers: {53 // Add reducers for additional action types here, and handle loading state as neede54 [getTokenFromStorage.fulfilled]: (state, { payload }) => {55 // If there is no token, then user is not authenticated56 if (payload.token === null) {57 state.isUserAuthenticated = false58 return59 }6061 // if there is a token, then user gets authenticated62 state.isUserAuthenticated = true63 state.token = payload.token6465 // Check for email in token66 if (payload.decodedEmail) {67 state.decodedEmail = payload.decodedEmail68 }6970 // check if user is authenticated (e.g. token expired)71 if (payload.hasCompletedProfile === 401) {72 state.hasCompletedProfile = false73 console.log("EXPIRED", payload.hasCompletedProfile)74 return75 }7677 // check for completed profile78 if (79 payload.hasCompletedProfile === 404 ||80 payload.hasCompletedProfile === []81 ) {82 state.hasCompletedProfile = false83 return84 }8586 state.brand = payload.hasCompletedProfile87 state.brandImage = `https://pinfluencer-product-images.s3.eu-west-2.amazonaws.com/${payload.hasCompletedProfile.id}/image`8889 state.hasCompletedProfile = true90 console.log("my brand", state.brand)91 },92 },93})9495// Action creators are generated for each case reducer function96export const {97 logOut,98 setCompletedProfile,99 setImage,100} = authenticationSlice.actions101102export default authenticationSlice.reducer
In order to implement a user-friendly and accessibility-focused app,
formik
was used, allowing us to create fully-custom input components with advanced verification systems and other features.jsx1// Extract of the Formik component used to create more custom text fields2<Wrapper>3 <Formik4 enableReinitialize5 validationSchema={FormSchema}6 initialValues={values}7 onSubmit={async (getFormValues, { setSubmitting }) => {8 setSubmitting(true)9 await submitForm(getFormValues)10 setSubmitting(false)11 }}12 >13 {({ isValid, isSubmitting, setFieldValue }) => (14 <Form>15 {props.editBrand ? (16 <DefaultImageSelector17 brandID={brand?.id}18 uploader={19 <ImageUploader20 label="Featured image"21 receiveFileName={el => {22 setFieldValue("filename", el)23 }}24 receiveBase64={el => {25 setFieldValue("bytes", el.split("base64,")[1])26 }}27 />28 }29 />30 ) : (31 <ImageUploader32 label="Featured image"33 receiveFileName={el => {34 setFieldValue("filename", el)35 }}36 receiveBase64={el => {37 setFieldValue("bytes", el.split("base64,")[1])38 }}39 />40 )}41 <TextField42 placeholder="Email"43 type="email"44 name="email"45 disabled={decodedEmail}46 />47 <TextField placeholder="Name" type="text" name="name" />48 <TextField49 placeholder="Business Description"50 type="text"51 name="description"52 />53 <TextField54 placeholder="Instagram handle"55 type="text"56 name="instahandle"57 />58 <TextField placeholder="Website" type="text" name="website" />59 {!props.editBrand ? <Logout text="Cancel" /> : null}60 <Button isDisabled={!isValid} type="submit">61 {isSubmitting62 ? "Loading..."63 : `${props.editBrand ? "Update brand" : "Create my brand"}`}64 </Button>65 {data ? <Error>{data}!</Error> : null}66 </Form>67 )}68 </Formik>69</Wrapper>
Products could be created and edited through an edit-mode interface, a system that was created from the ground up.
jsx1// Extract of the single product component, which included the edit mode functionality2<Wrapper>3 <Header>4 <HeaderContents>5 <BackButton />6 <H3>{modifiedProduct?.name || "No name"}</H3>7 {renderButtons()}8 </HeaderContents>9 </Header>10 <ImageWrapper>11 {product ? (12 <Image src={getImage("product", product?.brand?.id, product?.id)} />13 ) : null}14 {editMode ? (15 <EditButton16 title="Name"17 type="name"18 position="top right"19 content={20 <EditImage21 type="name"22 getInitialValues={{ filename: "", bytes: "" }}23 getModifiedValues={modifyProduct}24 />25 }26 />27 ) : null}28 </ImageWrapper>29 <StandardLayout>30 <Text>31 <H1>Product name</H1>32 <P>{modifiedProduct?.name}</P>33 {editMode ? (34 <EditButton35 title="Name"36 type="name"37 position="top right"38 content={39 <EditSingleField40 type="name"41 getInitialValues={{ name: product?.name }}42 getModifiedValues={modifyProduct}43 />44 }45 />46 ) : null}47 </Text>48 <Text>49 <H1>About the brand</H1>50 <P>I create designs for eco-friendly clothing.</P>51 </Text>52 <Text>53 <H1>About the product</H1>54 <P>{modifiedProduct?.description || "No description"}</P>55 {editMode ? (56 <EditButton57 title="Description"58 type="name"59 position="top right"60 content={61 <EditSingleField62 type="description"63 getInitialValues={{ description: product?.description }}64 getModifiedValues={modifyProduct}65 />66 }67 />68 ) : null}69 </Text>70 <Text>71 <H1>Influencer requirements</H1>72 <InfluencerRequirements73 requirements={modifiedProduct?.requirements || "No requirements"}74 />75 {editMode ? (76 <EditButton77 title="Influencer Requirements"78 type="name"79 position="top right"80 content={81 <EditSingleField82 type="requirements"83 getInitialValues={{ requirements: product?.requirements }}84 getModifiedValues={modifyProduct}85 />86 }87 />88 ) : null}89 </Text>90 <Text>91 <H1>What you'll get</H1>92 <UnorderedList>93 <ListItem>🎁 Gifted product</ListItem>94 </UnorderedList>95 </Text>96 </StandardLayout>97</Wrapper>
Pinfluencer's Landing page (Static version)

In order to promote Pinfluencer's products, the team decided to create a custom landing page. I was in charge of designing and implementing the site, which I decided to build using Gatsby.js and styled-components. A much more simple product compared to the previous two, but taking advantage of Gatsby's full potential with GraphQL, several plugins and custom seo:
js1// Custom SEO component using react-helmet2function SEO({ description, lang, meta, keywords, title }) {3 return (4 <StaticQuery5 query={detailsQuery}6 render={data => {7 const metaDescription =8 description || data.site.siteMetadata.description9 return (10 <Helmet11 htmlAttributes={{12 lang,13 }}14 title={title}15 titleTemplate={`%s | ${data.site.siteMetadata.title}`}16 meta={[17 {18 name: `description`,19 content: metaDescription,20 },21 {22 property: `og:title`,23 content: title,24 },25 {26 property: `og:description`,27 content: metaDescription,28 },29 {30 property: `og:type`,31 content: `website`,32 },33 {34 name: `twitter:card`,35 content: `summary`,36 },37 {38 name: `twitter:creator`,39 content: data.site.siteMetadata.author,40 },41 {42 name: `twitter:title`,43 content: title,44 },45 {46 name: `twitter:description`,47 content: metaDescription,48 },49 ]50 .concat(51 keywords.length > 052 ? {53 name: `keywords`,54 content: keywords.join(`, `),55 }56 : []57 )58 .concat(meta)}59 />60 )61 }}62 />63 )64}6566SEO.defaultProps = {67 lang: `en`,68 meta: [],69 keywords: [],70}7172SEO.propTypes = {73 description: PropTypes.string,74 lang: PropTypes.string,75 meta: PropTypes.array,76 keywords: PropTypes.arrayOf(PropTypes.string),77 title: PropTypes.string.isRequired,78}7980export default SEO8182const detailsQuery = graphql`83 query DefaultSEOQuery {84 site {85 siteMetadata {86 title87 description88 author89 }90 }91 }92`