Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/Web Development/KO

QR Entry Campaign Site with Guest Participation

QR Entry Campaign Site with Guest Participation

A three month retro heritage entry campaign starting from printed QR codes

Telling two printed QR codes apart

This was the web entry form for a heritage campaign that brought back retro packaging from the 1940s through the 1990s. It was the page that printed QR codes led to.

The requirements were specific. No app, web only. Non-members had to be able to enter with just a name and a contact number. Members earned 20 points per entry, and those points fed into the odds for the third and fourth draws. One person could enter at most three times a day. On top of that: four social logins (Naver, Kakao, Facebook, Google), Korean and English, and IE11 support.

The QR codes went out in two versions, one printed for regular consumers and one for partner channels (distributors and retail partners). Both had to open the same campaign page while the server still recorded which QR an entry came from. Same screen, different tag. That requirement decided most of the design work I describe below.

Three months live, no server

I shipped a Create React App SPA to static hosting and ran no server.

The page was coming down in three months, and traffic spikes exactly when people scan a QR code. Server rendering had almost nothing to offer here. Put the bundle up and the edge absorbs a scan surge on its own. The campaign page still had to be findable in search. So I swapped meta tags per screen with react-helmet (src/Utils/Meta.js), and generated public/sitemap.xml before the build with react-router-sitemap, wired up as a predeploy script in package.json.

I built login and signup as page states inside a modal, not as screens

On this site login is not a destination, it is an obstacle that interrupts the entry funnel. Pulling it out into a route like /login means sending the visitor off the event page, and people drop on the way back. So I opened a modal through popupState.login and swapped member type, agreements, information entry, and completion inside it with popUpPage values 1 through 7. The cost is obvious. You cannot close the modal with the back button, and you cannot link to step 3 of signup. On a page with a three month lifespan I accepted that.

I kept the access token in memory only and stored just the refresh token in localStorage

loginSuccess puts the access token nowhere but axios.defaults.headers.common["Authorization"] (src/Service/UserApi.js:91). This is an entry form that handles addresses and phone numbers, so I did not want an access token sitting around in storage. In exchange, one refresh wipes the access token entirely. That leaves protected screens sitting empty, which I cover in detail below.

Deployment and infrastructure
Deployment and infrastructure

One static bundle, four endpoints

The bundle is one lump of static files produced by CRA. Judging by the s3.ap-northeast-2.amazonaws.com references left in the code, the static files and the video and image assets went into the same bucket and came down through an edge cache. Routing happens entirely in the browser, and whatever path you arrive on, the server hands back the same index.html. That is what lets /newtropepsicustomer and /newtropepsipartner hang off the same Event component (src/Components/Router/MainRouter.js:59).

This piece only goes deep on carrying the QR origin through to the server and on how this static bundle is put together. The four external endpoints the browser opens directly (social login, address search, the Instagram feed, pageview collection), the state split across three Contexts, multi language bundling and IE11 support, refresh timing, hardcoded configuration, and the address schema problem are each worth their own write up, so I leave them out here.

Core data model
Core data model

Keeping a printed QR code's origin alive all the way to the entry receipt

The usual move is a query string. Append ?from=partner to the partner QR, then pull it out of location.search when you build the entry payload. With a little more care you tuck it into React Router's location.state or into a Context and pass it down the funnel. Both break in this campaign. Naver login is not an SDK popup: the whole page navigates to nid.naver.com and comes back to /oAuth, so in memory state is gone, and the query string does not survive that round trip plus the move to /quiz. Between answering the quiz, opening three goods popups, and reaching the entry form, screens unmount several times over.

I moved the origin into browser storage at the moment the route matched. When the Event component renders it checks match.url, and on the partner path it writes localStorage.setItem("partner", true). At the point where the entry payload is built, it reads back as exclusive: localStorage.getItem("partner") ? localStorage.getItem("partner") : false, and the moment the entry succeeds and the completion screen appears, localStorage.removeItem("partner") clears it. Both the member and the guest path follow the same rule.

localStorage was the only store that survives both a full page navigation and an SPA unmount. What matters more is where I put the removal: on the entry success screen. Because the flag's lifespan is scoped to one entry rather than to a session, someone who arrives on a partner QR and enters once, then enters a second time, has that second entry counted as a regular one. The lifespan in the code now matches the campaign side's definition, where one printed piece corresponds to one entry.

Keeping my page from rendering empty while the access token is still missing

Call once on mount and, right after a refresh, there is no access token yet, so you take a 401 and the screen stays stuck on loading.

I lifted a successful token refresh into an acc state, and had the protected screens subscribe to state.acc so that when the token arrives late they re-request on their own. If loading runs past two seconds, a retry button appears.

It removes the race over whether the token or the screen comes first, so nothing is left blank after a refresh. The full story is worth writing up separately, so I stop here.

What a user can do

Finish signing up with an email address
Finish signing up with an email address

Log in with an email and password
Log in with an email and password

Log in with a social account
Log in with a social account

Scan a printed QR code and land on the campaign page
Scan a printed QR code and land on the campaign page

Guess which year a retro can is from
Guess which year a retro can is from

Enter as a member by ranking three pieces of merch
Enter as a member by ranking three pieces of merch

Enter with just a name and an address, without logging in
Enter with just a name and an address, without logging in

Browse the brand and its history by decade
Browse the brand and its history by decade

See the lookbook, the videos, and the Instagram feed in the magazine
See the lookbook, the videos, and the Instagram feed in the magazine

Switch between Korean and English from the menu
Switch between Korean and English from the menu

Check my entries and the merch I picked
Check my entries and the merch I picked

Change the shipping address and contact number
Change the shipping address and contact number

Change the password
Change the password

Close the account
Close the account

Read the privacy policy and the terms of use
Read the privacy policy and the terms of use

Arrive on a path that does not exist and head back to the main page
Arrive on a path that does not exist and head back to the main page

1 / 1

Answers in the browser, master broken

The quiz is the part I am least proud of. All five questions and their answers sit right there in the AllQuizList array at src/Components/Pages/Quiz.js:122, and the browser grades them too. For anyone who can open devtools it is not a quiz, it is one click. The only real line of defense is the daily events limit is over the server returns, the three per day cap. If I built it again I would fetch only the question and the choices from the server and fold answer checking and entry eligibility into a single server request. On an event with prizes attached, putting the answers on the client was not a convenience, it was a mistake.

Branch management failed too. The last commit on master merges develop, and it was committed with the conflicts unresolved. <<<<<<< HEAD markers are still sitting in src/App.js, src/index.js, src/Service/UserApi.js, public/index.html, and package.json, so yarn build does not pass on master today. There are also 17 branches left on the remote, things like design, design2, design3, i18n, i18next, lang, modal, mv. That is what happens with nothing in place to stop a broken build from landing on the default branch. Running the build alone in CI would have caught it.

Read next

Apartment Price and Transaction Lookup Webview

Banksalad Housing Tab — 2020