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 Main Dashboard

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:

json
1{
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.

Portal Main Dashboard

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.

vue
1<!-- Example of a component using vue-slider-component, which allows the user to select a range of different categories. -->
2<template>
3 <div
4 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></small
12 >
13 <h6 class="col-8 text-center">{{ headingName }}</h6>
14 <small class="col-2 text-right"
15 >{{ max }} <span v-if="maxLabel">{{ maxLabel }}</span></small
16 >
17 </div>
18
19 <vue-slider
20 v-model="range"
21 :enable-cross="false"
22 :min="min"
23 :max="max"
24 :interval="interval"
25 hide-label
26 @change="$emit('change', range)"
27 />
28 </div>
29</template>

Portal Main Dashboard

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.

vue
1<!-- 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-input
8 v-if="type !== 'textarea'"
9 b-form-input
10 :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 }}</span
20 >
21 </div>
22 <div v-if="type == 'textarea'" class="PInput__textarea">
23 <textarea
24 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 }}</span
35 >
36 </div>
37 </b-container>
38 <span v-show="warningMode" class="PInput__warningModeText"
39 ><i style="margin-right: 5px" class="fas fa-exclamation-triangle"></i
40 >{{ warningModeText }}</span
41 >
42 </div>
43</template>

Portal Main Dashboard

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.

vue
1<!-- 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>reset
7 images</span
8 >
9 <div class="PImageUploader">
10 <div
11 class="PImageUploader__image"
12 v-for="(item, index) in items"
13 :key="index"
14 >
15 <div
16 v-if="!item.image"
17 @click="launchFilePicker"
18 class="
19 image-placeholder
20 d-flex
21 justify-content-center
22 align-items-center
23 "
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>
32
33 <input
34 ref="file"
35 type="file"
36 style="display: none"
37 @change="onFileChange(item, $event)"
38 />
39 </div>
40 </div>
41 <PCounter
42 :count="uploadedImagesCount"
43 :limit="5"
44 :ok="uploadedImagesCount < 5"
45 :breach="uploadedImagesCount === 5"
46 />
47 </div>
48</template>
49
50<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"
66
67 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).length
74 },
75 },
76 methods: {
77 launchFilePicker() {
78 this.$refs.file[0].click()
79 },
80 onFileChange(item, e) {
81 const files = e.target.files || e.dataTransfer.files
82 if (!files.length) return
83 this.createImage(item, files[0])
84 },
85 createImage(item, file) {
86 const image = new Image()
87 const reader = new FileReader()
88
89 const index = this.items.findIndex(item => !item.image)
90
91 reader.onload = e => {
92 this.items[index].image = e.target.result
93 }
94 reader.readAsDataURL(file)
95
96 this.files.push({ image: file })
97
98 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 = false
109 },
110 },
111}
112</script>
113
114<style lang="scss" scoped>
115@import "./assets/css/main.scss";
116
117label {
118 font-weight: 700;
119}
120.wrapper {
121 position: relative;
122
123 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}
134
135.PImageUploader {
136 display: flex;
137 align-items: center;
138 justify-content: flex-start;
139
140 &__image {
141 display: inline-block;
142 margin-right: 10px !important;
143
144 .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 }
152
153 .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.

vue
1<!-- 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 <p
14 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 <input
25 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 <p
44 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 <input
55 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>
66
67<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 return
82 }
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 return
91 }
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 }
102
103 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.

Portal Main Dashboard

vue
1<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 <i
14 class="fa fa-circle"
15 style="color: #dddddd"
16 aria-hidden="true"
17 ></i>
18 <span>{{ el.template.title }}</span>
19 <i
20 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 <span
29 >{{ el.influencer.name.first }}
30 {{ el.influencer.name.last }}</span
31 >
32 <i
33 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 <i
41 v-if="el.process.id == 'complete'"
42 class="fas fa-check-circle"
43 ></i>
44 {{ el.process.displayName }}
45 </span>
46 </td>
47
48 <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>
52
53 <td v-if="el.deliverables" class="deliverables">
54 <span v-for="(deliverable, ind) in el.deliverables" :key="ind">
55 {{ deliverable.amount }} x
56 {{ deliverable.displayName }}
57 </span>
58 </td>
59
60 <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>
66
67 <td v-if="el.budget" class="budget">
68 <span>
69 {{ el.budget }}
70 </span>
71 </td>
72
73 <td>
74 <div class="actions">
75 <a
76 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 <a
84 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>

Date range

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.

Profiles

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.

Brands

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:

json
1"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.

js
1// Main auth reducer used to authenticate the user when accessing the app.
2
3import jwt_decode from "jwt-decode"
4import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"
5import { getBrandInfo } from "../../services/resources"
6
7export 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)
13
14 // 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)
21
22export const authenticationSlice = createSlice({
23 name: "authentication",
24 initialState: {
25 // User token
26 token: null,
27 // Returns true if user is authenticated
28 isUserAuthenticated: null,
29 // Returns true if user has a brand
30 hasCompletedProfile: true,
31 // Returns the user's brand
32 brand: null,
33 // If token includes an email, store it in state
34 decodedEmail: null,
35 // Returns brand image
36 brandImage: null,
37 },
38 reducers: {
39 logOut: state => {
40 state.token = null
41 state.isUserAuthenticated = false
42 localStorage.removeItem("@pinfluencer-auth")
43 },
44 setCompletedProfile: (state, { payload }) => {
45 state.hasCompletedProfile = payload
46 },
47 setImage: (state, { payload }) => {
48 state.brandImage = payload
49 console.log("changing from reducer", payload)
50 },
51 },
52 extraReducers: {
53 // Add reducers for additional action types here, and handle loading state as neede
54 [getTokenFromStorage.fulfilled]: (state, { payload }) => {
55 // If there is no token, then user is not authenticated
56 if (payload.token === null) {
57 state.isUserAuthenticated = false
58 return
59 }
60
61 // if there is a token, then user gets authenticated
62 state.isUserAuthenticated = true
63 state.token = payload.token
64
65 // Check for email in token
66 if (payload.decodedEmail) {
67 state.decodedEmail = payload.decodedEmail
68 }
69
70 // check if user is authenticated (e.g. token expired)
71 if (payload.hasCompletedProfile === 401) {
72 state.hasCompletedProfile = false
73 console.log("EXPIRED", payload.hasCompletedProfile)
74 return
75 }
76
77 // check for completed profile
78 if (
79 payload.hasCompletedProfile === 404 ||
80 payload.hasCompletedProfile === []
81 ) {
82 state.hasCompletedProfile = false
83 return
84 }
85
86 state.brand = payload.hasCompletedProfile
87 state.brandImage = `https://pinfluencer-product-images.s3.eu-west-2.amazonaws.com/${payload.hasCompletedProfile.id}/image`
88
89 state.hasCompletedProfile = true
90 console.log("my brand", state.brand)
91 },
92 },
93})
94
95// Action creators are generated for each case reducer function
96export const {
97 logOut,
98 setCompletedProfile,
99 setImage,
100} = authenticationSlice.actions
101
102export 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.

jsx
1// Extract of the Formik component used to create more custom text fields
2<Wrapper>
3 <Formik
4 enableReinitialize
5 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 <DefaultImageSelector
17 brandID={brand?.id}
18 uploader={
19 <ImageUploader
20 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 <ImageUploader
32 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 <TextField
42 placeholder="Email"
43 type="email"
44 name="email"
45 disabled={decodedEmail}
46 />
47 <TextField placeholder="Name" type="text" name="name" />
48 <TextField
49 placeholder="Business Description"
50 type="text"
51 name="description"
52 />
53 <TextField
54 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 {isSubmitting
62 ? "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.

jsx
1// Extract of the single product component, which included the edit mode functionality
2<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 <EditButton
16 title="Name"
17 type="name"
18 position="top right"
19 content={
20 <EditImage
21 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 <EditButton
35 title="Name"
36 type="name"
37 position="top right"
38 content={
39 <EditSingleField
40 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 <EditButton
57 title="Description"
58 type="name"
59 position="top right"
60 content={
61 <EditSingleField
62 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 <InfluencerRequirements
73 requirements={modifiedProduct?.requirements || "No requirements"}
74 />
75 {editMode ? (
76 <EditButton
77 title="Influencer Requirements"
78 type="name"
79 position="top right"
80 content={
81 <EditSingleField
82 type="requirements"
83 getInitialValues={{ requirements: product?.requirements }}
84 getModifiedValues={modifyProduct}
85 />
86 }
87 />
88 ) : null}
89 </Text>
90 <Text>
91 <H1>What you&apos;ll get</H1>
92 <UnorderedList>
93 <ListItem>🎁 Gifted product</ListItem>
94 </UnorderedList>
95 </Text>
96 </StandardLayout>
97</Wrapper>

Pinfluencer's Landing page (Static version)

Static

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:

js
1// Custom SEO component using react-helmet
2function SEO({ description, lang, meta, keywords, title }) {
3 return (
4 <StaticQuery
5 query={detailsQuery}
6 render={data => {
7 const metaDescription =
8 description || data.site.siteMetadata.description
9 return (
10 <Helmet
11 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 > 0
52 ? {
53 name: `keywords`,
54 content: keywords.join(`, `),
55 }
56 : []
57 )
58 .concat(meta)}
59 />
60 )
61 }}
62 />
63 )
64}
65
66SEO.defaultProps = {
67 lang: `en`,
68 meta: [],
69 keywords: [],
70}
71
72SEO.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}
79
80export default SEO
81
82const detailsQuery = graphql`
83 query DefaultSEOQuery {
84 site {
85 siteMetadata {
86 title
87 description
88 author
89 }
90 }
91 }
92`