Skip to content

Interested in AI, automation, blockchain, web and apps

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

Work/Web Development/KO

Marketing Site for Tax Agency and Management SaaS

Marketing Site for Tax Agency and Management SaaS

A marketing site introducing seven features across three tax services

Seven features with nowhere to explain them

The site had to introduce two things in one place: an agency service, and the management SaaS the client companies use. There were seven features (work tickets, attendance, org management, e-signature, cash management, receivables and payables, insight) and three services. Three requirements: a CMS where case studies and customer interview videos can be published and unpublished directly from the admin, a blog running inside the company domain, and consultation and free trial forms that hand the request straight to the product API.

System boundary and external dependencies
System boundary and external dependencies

Next.js renders, NestJS routes

A single process with Next.js embedded in NestJS as the render engine

Admin CRUD, file uploads and JWT auth were needed. Splitting the frontend from the backend would have meant two repositories and two deployments. With nest-next's RenderModule, a controller can point directly at a page under pages/views with @Render('blog'), so routing and data fetching stay in one place. It was a two month schedule staffed mostly with frontend people, so there was no room to stand up and maintain a separate API server.

TypeORM with MySQL on RDS, synchronize only when APP_ENV=development

Blog types and case study fields kept growing alongside the spec, so locally it was faster to edit the entity and let the table follow. Leaving it on in production can drop columns, so it was cut by environment. There was no time to add a migration tool and get the team used to the flow.

Uploads go through a resize step and land in S3 as public-read

Blog body images left at their original size run several MB each. @dicolabs-kr/nest-module.media-converter shrinks the buffer to 1500px wide, the file goes into the wecake-homepage-files bucket, and the DB stores only the public URL. There was no budget or time for CloudFront, so the S3 URL is exposed as is.

Elastic Beanstalk with GitHub Actions, .ebextensions forcing yarn and a 4GB swap

The default EB hooks try to install with npm, but this repository runs on yarn.lock. 49_yarn.sh was dropped in to make it use yarn, and the default 50npm.sh was overwritten with an empty file. Installing dependencies on the instance blew out its memory, so 4GB of swap was reserved alongside it. The dev environment had to run on one small instance, and instead of shipping node_modules in the deploy zip the install had to happen on the server.

Chakra UI with createBreakpoints

The design was a two column composition that split at 845px. Setting md: '845px' matched the breakpoint to the comps, and writing every spacing value as a { base, md } object let one component cover both layouts. There was no design system, and components had to be built as the comps came in.

Deployment and infrastructure
Deployment and infrastructure

The controller names the page

A request reaches a Nest controller first. AppController.index() reads the latest 6 rows from Blog and 7 from Customer, returns them with @Render('home'), and nest-next carries that return value into Next's ctx.query. The page pulls ctx.query.blogs out in getInitialProps or getServerSideProps and uses it as props. That is why this site has almost no API routes for data fetching.

There are only two exceptions. Blog load more (GET /api/blog?page=2&slug=insight) and the banner rotating beside the request modal (GET /api/advertisements) are the only calls the browser makes itself. There is one outbound request too: the consultation and free trial forms POST https://api.wecake.io/consultings to hand the lead to the product. Images and video pass through /admin/image or each form's multipart handler, get resized, land in S3, and the DB keeps only the URL string. The admin lives at /admin/* in the same process, and JwtAuthGuard reads the token from an httpOnly cookie to block it.

Core data model
Core data model

Swapping typefaces one character at a time with unicode-range

When you want Hangul in Noto Sans KR and Latin letters and digits in Poppins, the first idea that comes to mind is a fallback list like font-family: Poppins, 'Noto Sans KR'. The other is to wrap each Latin fragment in a <span> per component and set the font there. The first gives you no control over which face digits, parentheses and the won sign fall into. The second cannot be applied at all to body HTML coming out of the admin.

I declared eight @font-face blocks and gave every one of them the same font-family, 'Noto Sans KR'. In exchange the four Hangul faces got unicode-range: U+AC00-D7AF, and the four Latin faces, whose files are actually Poppins, got the alphabet, digit and symbol ranges plus the won sign U+20A9. The Chakra theme is handed one family, 'Noto Sans KR', for both heading and body.

The browser picks the face per glyph according to unicode-range, and downloads a file only when a character in that range actually appears. So the markup needs no changes, and body content coming from the admin or a button label mixing Hangul and English splits on its own. Since all four weights (300/400/500/700) are prepared on both sides, changing fontWeight moves both typefaces together.

Export the isometric illustration as a PNG, lay it down, and place a transparent position: absolute <div> over each building by coordinate. The coordinates have to be remeasured at every breakpoint, the hover popup has to be drawn on a separate layer outside the image, and the links never reach the HTML so crawlers cannot follow them.

I inlined the whole illustration as SVG (JSX). The single file source/home/hero/illustration/index.tsx runs past 9,000 lines. In exchange, the seven features are wrapped in <a href="/feature/bond-and-debt"> inside the SVG, and each link holds a <g className="popup"> that rises on hover with opacity and translateY. Fill colors are planted inline per path as --fill / --hover-fill CSS variables and handled by one rule, a:hover > path:first-child { color: var(--hover-fill) }. The TV in the middle has an onClick on g.play-button that opens the brand video modal.

Because the coordinates are defined inside viewBox="0 0 1532 702", the hit areas and the popups track the drawing at exactly the same ratio no matter how wide the container gets. Adding a breakpoint leaves nothing to remeasure. The links are real <a href>, so they stay in the HTML the server drew and they open in a new tab. Pulling the colors into variables saved writing a hover rule for each of dozens of paths. The price was a bigger bundle, but this drawing was the entire first screen of the home page, so it was a trade worth taking.

What a user can do

The admin logs in and reaches the content screens
The admin logs in and reaches the content screens

Going from the home illustration straight into the feature you wonder about
Going from the home illustration straight into the feature you wonder about

Pressing the TV inside the hero to watch the brand video
Pressing the TV inside the hero to watch the brand video

Filling in the free trial request across two steps and sending it
Filling in the free trial request across two steps and sending it

Requesting a consultation from the bottom banner on any page
Requesting a consultation from the bottom banner on any page

Rolling through the blog and narrowing it down by category
Rolling through the blog and narrowing it down by category

Opening one blog post and reading it to the end
Opening one blog post and reading it to the end

Filtering case studies by industry and reading one
Filtering case studies by industry and reading one

Reading the service pages and comparing the pricing table
Reading the service pages and comparing the pricing table

Looking at the team page and checking the terms pages too
Looking at the team page and checking the terms pages too

The admin writes a blog post and uploads a cover image
The admin writes a blog post and uploads a cover image

The admin registers a case study and decides what shows on the home page
The admin registers a case study and decides what shows on the home page

The admin registers an interview video to run on the home page
The admin registers an interview video to run on the home page

The admin swaps the banner that shows in the request modal
The admin swaps the banner that shows in the request modal

Watching a customer interview video on the home page
Watching a customer interview video on the home page

1 / 1

The cost of one global !important

Not everything got a clean finish. The one that still bothers me most is letter spacing. There is a global * { letter-spacing: -1.25px !important; }, and after mount setLatinLetterSpacing() tries to undo it for Latin runs by wrapping them in <span style="letter-spacing: 0">. But in the CSS cascade an !important declaration beats a normal inline one. That correction was effectively never applied. Today I would strip the global !important and give the Latin class an ordinary rule.

The case study categories are the same kind of thing. The DB stores an integer and the meaning is written down in three separate places: the entity comment, categoryNumToEnum in the controller, and CASE_CATEGORY on the frontend. The values happen to line up, but the names contradict the meanings. MEDICAL is food service and franchise, IT is logistics and distribution. This should have gone to an enum column or a category table.

Beyond that, the admin password is compared in plain text with user.password === password, and with no migrations and synchronize on only in development, the production schema was matched by hand. pages/views/admin/advertisement/detail.tsx is a copy of customer/detail.tsx that sits there wired to no route. I let these go on the judgement that there was a single admin and this was a short lived marketing site, but all three were one or two lines of work.

Read next

Online Class Site for Video Lectures and Scratch Practice

Scratch Tutoring — 2021