Mobile‑First Compact Dashboard & WhatsApp “Finish” Bot in a Next.js 13 App
TL;DR: I refactored the ticket‑panel UI to a mobile‑first layout with a bottom TabBar, collapsible filters, and responsive CSS. I also added a “Finish” button to the WhatsApp bot flow. The changes live in app/*, components/TabBar.tsx, and lib/wa‑bot.ts.
The Problem
The original albaida‑tickets panel was built for desktop browsers. When the same view was opened on a phone:
- The sidebar pushed the main content off‑screen.
- Filter controls were always expanded, consuming most of the vertical space.
- There was no persistent navigation for the most common actions (My tickets, New ticket, Settings).
On the bot side, users could navigate the menu but never had a clear way to signal that they were done filing a complaint. The flow ended abruptly after the last message, leaving the conversation hanging.
Both issues manifested as UI/UX dead‑ends, not as runtime errors, but they broke the core promise of the app: “manage tickets from anywhere”.
What I Tried First
1. Pure CSS Media Queries
I added a few @media (max-width: 640px) rules to shrink the sidebar and hide some elements. The layout technically fit on the screen, but:
- The hidden sidebar still rendered on the server, causing a flash of unstyled content (FOUC).
- The filter panel remained a static block; users could not collapse it, so the page still felt cramped.
2. Conditional Rendering in app/panel/page.tsx
I wrapped the sidebar in a if (isMobile) return null check using useEffect + window.innerWidth. This broke the server‑component contract because app/panel/page.tsx is a server component; accessing window caused a ReferenceError: window is not defined during SSR.
Both attempts failed to give a clean, maintainable solution.
The Implementation
1. Global CSS – app/globals.css
I expanded the CSS file by 116 lines to introduce a mobile‑first utility set and a new bottom navigation bar.
/* globals.css – added mobile utilities */
@media (max-width: 640px) {
/* Collapse the side panel */
.sidebar { display: none; }
/* Make the main container full‑width */
.main { margin-left: 0; padding: 0 1rem; }
/* Bottom tab bar – fixed height */
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 56px;
background: var(--bg-primary);
display: flex;
justify-content: space-around;
align-items: center;
border-top: 1px solid var(--border);
z-index: 1000;
}
/* Card layout – single column */
.ticket-card {
width: 100%;
margin-bottom: 1rem;
}
/* Collapsible filter header */
details.filter {
margin-bottom: 0.5rem;
}
details.filter > summary {
cursor: pointer;
list-style: none;
}
}
Key points:
- The mobile utilities are scoped under a single
@mediablock, keeping the desktop styles untouched. - The
.tab-barclass will be used by the newTabBarcomponent. - Collapsible filters use native
<details>/<summary>elements, eliminating the need for extra JS.
2. Viewport Configuration – app/layout.tsx
A single line was added to the viewport export:
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover", // <- new line
themeColor: [{ media: "(prefers-color-scheme: dark)", color: "#000000" }],
};
viewportFit: "cover" tells iOS Safari to render the page edge‑to‑edge, preventing the default “letterbox” on iPhone X‑class devices.
3. Panel Layout – app/panel/layout.tsx
I imported the new TabBar component (the import line is the only change in this file):
import { NavLink } from "@/components/NavLink";
import TabBar from "@/components/TabBar"; // <- new import
The layout now renders TabBar only on mobile:
export default function PanelLayout({ children }: { children: ReactNode }) {
const isMobile = typeof window !== "undefined" && window.innerWidth <= 640;
return (
<>
{children}
{isMobile && <TabBar />}
</>
);
}
Because PanelLayout runs on the client (via the use client directive in TabBar), the window check is safe.
4. Panel Page – app/panel/page.tsx
The diff added 127 lines and removed 93, essentially refactoring the UI to use the new CSS classes and <details> for filters.
Before (simplified)
<div className="filters">
<select>…</select>
<input type="date" />
</div>
<div className="cards">{tickets.map(renderCard)}</div>
After
{/* Mobile‑first filter block */}
<details className="filter" open>
<summary className="font-medium">Filters</summary>
<div className="mt-2 space-y-2">
<select name="status" className="w-full rounded border">
{/* options */}
</select>
<input type="date" className="w-full rounded border" />
</div>
</details>
{/* Ticket cards – now responsive */}
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{tickets.map((t) => (
<article key={t.id} className="ticket-card p-4 rounded shadow">
{/* card content */}
</article>
))}
</section>
- The
<details>element gives us a native collapsible UI without extra state. - The
gridlayout automatically collapses to a single column on mobile, thanks to the CSS utilities already present. - All class names (
ticket-card,filter) map directly to the new rules inglobals.css.
5. Bottom Navigation – components/TabBar.tsx
A brand‑new client component (62 lines) that renders three navigation links:
tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
const tabs = [
{ href: "/panel", label: "Tickets", icon: "📋" },
{ href: "/panel/new", label: "New", icon: "➕" },
{ href: "/settings", label: "Settings", icon: "⚙️" },
];
export default function
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/albaida-tickets` · 2026-10-24*
\#playadev #buildinpublic
Top comments (0)