Merge pull request 'feat: enhance UI components and implement password reset flow with improved styling and validation' (#1) from Sasanka/auth into production

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-10 05:15:31 +00:00
17 changed files with 60 additions and 55 deletions
+4 -1
View File
@@ -12,7 +12,10 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [ ] `ProblemDetails` normalizer + `code → message` map (`lib/`) - [ ] `ProblemDetails` normalizer + `code → message` map (`lib/`)
## 1. Auth ## 1. Auth
- [ ] Login screen `POST /auth/login`; store token; attach to client - [~] Login screen — UI built (`app/login`); not yet wired to `POST /auth/login` / token storage
- [~] Forgot password — add email screen — UI built (`app/login/forgot`); not yet wired to API
- [~] Forgot password — verify OTP screen — UI built (`app/login/forgot/otp`); not yet wired to API
- [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API
## 2. Master Data screens ## 2. Master Data screens
- [ ] Items (list + create/edit, ETag handling) - [ ] Items (list + create/edit, ETag handling)
+2 -4
View File
@@ -1,5 +1,4 @@
import { AppSidebar } from "@/components/Layouts/AppSidebar" import { AppSidebar } from "@/components/Layouts/AppSidebar"
import { Footer } from "@/components/Layouts/Footer"
import { Header } from "@/components/Layouts/Header" import { Header } from "@/components/Layouts/Header"
import { Toaster } from "@/components/ui/toast" import { Toaster } from "@/components/ui/toast"
@@ -9,20 +8,19 @@ export default function DashboardLayout({
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
<div className="flex min-h-screen bg-background"> <div className="flex h-screen overflow-hidden bg-background">
<AppSidebar /> <AppSidebar />
<main className="flex flex-1 flex-col"> <main className="flex flex-1 flex-col">
<Header /> <Header />
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<div className="p-6 lg:p-8"> <div className="p-6 lg:p-8">
<div className="rounded-xl bg-card border border-border shadow-sm"> <div className="rounded-xl bg-card border border-gray-200 shadow-sm">
<div className="p-6"> <div className="p-6">
{children} {children}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<Footer className="border-t border-border mt-auto" />
</main> </main>
<Toaster /> <Toaster />
</div> </div>
+5 -5
View File
@@ -43,7 +43,7 @@ export default function DashboardPage() {
const [range, setRange] = React.useState<{ from: Date | undefined; to?: Date | undefined }>() const [range, setRange] = React.useState<{ from: Date | undefined; to?: Date | undefined }>()
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-5 rounded-xl bg-card p-6 shadow-sm border border-border"> <div className="flex flex-col gap-5 rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<div> <div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Breadcrumb</p> <p className="mb-2 text-sm font-semibold text-muted-foreground">Breadcrumb</p>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -232,7 +232,7 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
<div className="flex flex-col gap-5 rounded-xl bg-card p-6 shadow-sm border border-border"> <div className="flex flex-col gap-5 rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<div> <div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Alert Dialogs</p> <p className="mb-2 text-sm font-semibold text-muted-foreground">Alert Dialogs</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -294,7 +294,7 @@ export default function DashboardPage() {
<RecentOrdersTable /> <RecentOrdersTable />
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-3"> <div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="rounded-xl bg-card p-6 shadow-sm border border-border"> <div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<h3 className="mb-4 text-sm font-semibold text-foreground">Sales (Line)</h3> <h3 className="mb-4 text-sm font-semibold text-foreground">Sales (Line)</h3>
<div className="h-56"> <div className="h-56">
<LineChart <LineChart
@@ -304,7 +304,7 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
<div className="rounded-xl bg-card p-6 shadow-sm border border-border"> <div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<h3 className="mb-4 text-sm font-semibold text-foreground">Revenue (Bar)</h3> <h3 className="mb-4 text-sm font-semibold text-foreground">Revenue (Bar)</h3>
<div className="h-56"> <div className="h-56">
<BarChart <BarChart
@@ -314,7 +314,7 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
<div className="rounded-xl bg-card p-6 shadow-sm border border-border"> <div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<h3 className="mb-4 text-sm font-semibold text-foreground">Product Mix (Pie)</h3> <h3 className="mb-4 text-sm font-semibold text-foreground">Product Mix (Pie)</h3>
<div className="h-56"> <div className="h-56">
<PieChart labels={["A","B","C"]} data={[45, 30, 25]} /> <PieChart labels={["A","B","C"]} data={[45, 30, 25]} />
@@ -25,10 +25,10 @@ export default function ForgotOtpPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4">
<div className="w-full max-w-md rounded-xl bg-card p-8 shadow-lg border border-border"> <div className="w-full max-w-2xl rounded-xl bg-card p-8 shadow-lg border border-gray-200">
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col items-center mb-8">
<div className="bg-primary/10 rounded-full p-4 mb-4"> <div className="bg-black/10 rounded-full p-4 mb-4">
<Mail className="text-primary size-6" /> <Mail className="text-black size-6" />
</div> </div>
<h1 className="text-2xl font-bold text-foreground text-center">Verify your email</h1> <h1 className="text-2xl font-bold text-foreground text-center">Verify your email</h1>
</div> </div>
@@ -46,7 +46,7 @@ export default function ForgotOtpPage() {
e.preventDefault() e.preventDefault()
handleResend() handleResend()
}} }}
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors" className="text-sm font-medium text-black hover:text-black/80 transition-colors"
> >
Didn't receive the code? Resend Didn't receive the code? Resend
</button> </button>
@@ -57,10 +57,10 @@ export default function ForgotOtpPage() {
<div className="flex gap-3"> <div className="flex gap-3">
<Link href="/login" className="flex-1"> <Link href="/login" className="flex-1">
<Button variant="outline" className="w-full">Back</Button> <Button variant="outline" className="w-full h-12 rounded-full border-gray-300 text-black hover:bg-black/5">Back</Button>
</Link> </Link>
<Link href="/login/forgot/reset" className="flex-1"> <Link href="/login/forgot/reset" className="flex-1">
<Button className="w-full">Verify</Button> <Button className="w-full h-12 rounded-full bg-black text-white hover:bg-black/90">Verify</Button>
</Link> </Link>
</div> </div>
</div> </div>
+3 -13
View File
@@ -19,8 +19,8 @@ export default function ForgotEmailPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 px-4 py-8"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 px-4 py-8">
<div className="w-full max-w-md rounded-xl bg-card p-8 shadow-lg border border-border"> <div className="w-full max-w-2xl rounded-xl bg-card p-8 pl-10 shadow-lg border border-gray-200">
<div className="mb-8"> <div className="mb-8 text-center">
<h1 className="text-2xl font-bold text-foreground">Reset your password</h1> <h1 className="text-2xl font-bold text-foreground">Reset your password</h1>
<p className="mt-2 text-sm text-muted-foreground">Enter the email address associated with your account and we'll send you a one-time code to verify your identity.</p> <p className="mt-2 text-sm text-muted-foreground">Enter the email address associated with your account and we'll send you a one-time code to verify your identity.</p>
</div> </div>
@@ -41,7 +41,7 @@ export default function ForgotEmailPage() {
</div> </div>
<Button <Button
className="w-full h-11 text-base font-medium" className="w-full h-12 text-base font-medium rounded-full bg-black text-white hover:bg-black/90"
disabled={emailError !== ""} disabled={emailError !== ""}
onClick={() => { onClick={() => {
if (email.length === 0) { if (email.length === 0) {
@@ -53,16 +53,6 @@ export default function ForgotEmailPage() {
> >
Send verification code Send verification code
</Button> </Button>
<div className="text-center">
<button
type="button"
onClick={() => router.push('/login')}
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
>
Back to sign in
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -39,8 +39,8 @@ export default function ForgotResetPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4">
<div className="w-full max-w-md rounded-xl bg-card p-8 shadow-lg border border-border"> <div className="w-full max-w-xl rounded-xl bg-card p-8 shadow-lg border border-gray-200">
<div className="mb-8"> <div className="mb-8 text-center">
<h1 className="text-2xl font-bold text-foreground">Create a new password</h1> <h1 className="text-2xl font-bold text-foreground">Create a new password</h1>
<p className="mt-2 text-sm text-muted-foreground">Choose a strong password to secure your account.</p> <p className="mt-2 text-sm text-muted-foreground">Choose a strong password to secure your account.</p>
</div> </div>
@@ -55,7 +55,7 @@ export default function ForgotResetPage() {
value={password} value={password}
onChange={(e) => setPassword((e.target as HTMLInputElement).value)} onChange={(e) => setPassword((e.target as HTMLInputElement).value)}
className="h-11 pr-11" className="h-11 pr-11"
placeholder="••••••••" placeholder="Enter your new password"
/> />
<button <button
type="button" type="button"
@@ -76,7 +76,7 @@ export default function ForgotResetPage() {
value={confirm} value={confirm}
onChange={(e) => setConfirm((e.target as HTMLInputElement).value)} onChange={(e) => setConfirm((e.target as HTMLInputElement).value)}
className="h-11 pr-11" className="h-11 pr-11"
placeholder="••••••••" placeholder="Confirm your new password"
/> />
<button <button
type="button" type="button"
@@ -104,14 +104,14 @@ export default function ForgotResetPage() {
<Button <Button
variant="outline" variant="outline"
onClick={() => router.back()} onClick={() => router.back()}
className="flex-1 h-11" className="flex-1 h-12 rounded-full border-gray-300 text-black hover:bg-black/5"
> >
Back Back
</Button> </Button>
<Button <Button
disabled={!allValid} disabled={!allValid}
onClick={() => setShowSuccess(true)} onClick={() => setShowSuccess(true)}
className="flex-1 h-11" className="flex-1 h-12 rounded-full bg-black text-white hover:bg-black/90"
> >
Change password Change password
</Button> </Button>
@@ -119,7 +119,7 @@ export default function ForgotResetPage() {
</div> </div>
<Dialog open={showSuccess} onOpenChange={setShowSuccess}> <Dialog open={showSuccess} onOpenChange={setShowSuccess}>
<DialogContent className="w-full max-w-sm"> <DialogContent className="w-full max-w-lg sm:max-w-lg">
<DialogHeader className="text-center"> <DialogHeader className="text-center">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-success/10 text-success"> <div className="flex items-center justify-center w-12 h-12 rounded-full bg-success/10 text-success">
@@ -131,7 +131,7 @@ export default function ForgotResetPage() {
</div> </div>
</div> </div>
</DialogHeader> </DialogHeader>
<Button onClick={() => router.push('/login')} className="w-full mt-6"> <Button onClick={() => router.push('/login')} className="w-4/5 mx-auto h-12 mt-6 rounded-full bg-black text-white hover:bg-black/90">
Return to sign in Return to sign in
</Button> </Button>
</DialogContent> </DialogContent>
+3 -3
View File
@@ -106,7 +106,7 @@ export default function LoginPage() {
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
autoComplete="current-password" autoComplete="current-password"
className="h-11 rounded-lg px-4 pr-11 text-base" className="h-11 rounded-lg px-4 pr-11 text-base"
placeholder="••••••••" placeholder="Enter your password"
aria-invalid={!!form.formState.errors.password} aria-invalid={!!form.formState.errors.password}
{...form.register("password")} {...form.register("password")}
/> />
@@ -161,7 +161,7 @@ export default function LoginPage() {
<Button <Button
type="button" type="button"
className="w-full h-11 text-base font-medium inline-flex items-center justify-center gap-3 bg-black text-white border-0 hover:opacity-90" className="w-full h-11 text-base font-medium inline-flex items-center justify-center gap-3 rounded-full bg-transparent text-foreground border border-gray-300 hover:bg-black/5"
onClick={() => { onClick={() => {
alert('Google OAuth flow placeholder') alert('Google OAuth flow placeholder')
}} }}
@@ -173,7 +173,7 @@ export default function LoginPage() {
<div className="mt-12 text-center"> <div className="mt-12 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Don&apos;t have an account? <Link href="#" className="font-semibold text-primary hover:text-primary/80 transition-colors">Create an account</Link> Don&apos;t have an account? <Link href="#" className="font-semibold text-black hover:text-black/80 transition-colors">Create an account</Link>
</p> </p>
</div> </div>
</main> </main>
@@ -141,7 +141,7 @@ export function AppSidebar() {
return ( return (
<> <>
{/* ── Desktop sidebar ─────────────────────────────── */} {/* ── Desktop sidebar ─────────────────────────────── */}
<div className="hidden lg:flex"> <div className="my-3 hidden lg:my-4 lg:flex">
<SidebarContent <SidebarContent
collapsed={collapsed} collapsed={collapsed}
onCollapse={() => setCollapsed((v) => !v)} onCollapse={() => setCollapsed((v) => !v)}
@@ -6,11 +6,11 @@ export function Footer({ className }: { className?: string }) {
return ( return (
<footer <footer
className={cn( className={cn(
"w-full rounded-3xl bg-white shadow-sm ring-1 ring-black/5", "mx-6 w-auto rounded-3xl bg-white shadow-sm ring-1 ring-black/5 lg:mx-8",
className className
)} )}
> >
<div className="flex flex-col items-center gap-3 p-5"> <div className="flex flex-col items-center gap-1.5 p-3">
{/* Logo + name */} {/* Logo + name */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-lg bg-indigo-600"> <div className="flex size-7 items-center justify-center rounded-lg bg-indigo-600">
@@ -73,7 +73,7 @@ export function Header() {
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false }))) setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
return ( return (
<header className="mb-6 flex items-center justify-between gap-2 rounded-3xl bg-white py-4 pr-4 pl-14 shadow-sm ring-1 ring-black/5 lg:gap-4 lg:p-4"> <header className="mx-6 mt-3 mb-6 flex items-center justify-between gap-2 rounded-3xl bg-white py-4 pr-4 pl-14 shadow-sm ring-1 ring-black/5 lg:mx-8 lg:mt-4 lg:gap-4 lg:p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{showBackButton && ( {showBackButton && (
<button <button
+1 -1
View File
@@ -17,7 +17,7 @@ function Avatar({
data-slot="avatar" data-slot="avatar"
data-size={size} data-size={size}
className={cn( className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten", "group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-gray-200 after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className className
)} )}
{...props} {...props}
+1 -1
View File
@@ -15,7 +15,7 @@ const badgeVariants = cva(
destructive: destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline: outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", "border-gray-200 text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
+3 -3
View File
@@ -4,13 +4,13 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/60 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-sm hover:shadow-md", "group/button inline-flex shrink-0 items-center justify-center rounded-full border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/60 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-sm hover:shadow-md",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary", default: "bg-black text-white hover:bg-black/90 active:bg-black",
outline: outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/20 dark:hover:bg-input/40", "border-black bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/20 dark:hover:bg-input/40",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_8%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_8%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: ghost:
+1 -1
View File
@@ -42,7 +42,7 @@ function ToastList() {
<ToastPrimitive.Content className="flex items-start justify-between gap-3"> <ToastPrimitive.Content className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{toast.title && ( {toast.title && (
<ToastPrimitive.Title className="font-semibold leading-snug"> <ToastPrimitive.Title className="text-xs font-semibold leading-snug">
{toast.title} {toast.title}
</ToastPrimitive.Title> </ToastPrimitive.Title>
)} )}
+14 -4
View File
@@ -1,14 +1,24 @@
import { z } from "zod" import { z } from "zod"
// Treats null/undefined as missing so validation surfaces one clear
// "required" message instead of a generic type error.
function requiredString(message: string) {
return z.preprocess(
(val) => (val === null || val === undefined ? "" : val),
z.string().min(1, message)
)
}
// Email validation schema // Email validation schema
export const emailSchema = z.string().min(1, "Email is required").email("Enter a valid email") export const emailSchema = z.preprocess(
(val) => (val === null || val === undefined ? "" : val),
z.string().min(1, "Email is required").email("Enter a valid email")
)
// Login schema (email + password) for reuse across the app // Login schema (email + password) for reuse across the app
export const loginSchema = z.object({ export const loginSchema = z.object({
email: emailSchema, email: emailSchema,
password: z password: requiredString("Password is required")
.string()
.min(1, "Password is required")
.refine((val) => val.length >= 8, { message: "Password must be at least 8 characters" }) .refine((val) => val.length >= 8, { message: "Password must be at least 8 characters" })
.refine((val) => /[A-Z]/.test(val), { .refine((val) => /[A-Z]/.test(val), {
message: "Password must contain at least one uppercase letter", message: "Password must contain at least one uppercase letter",
+4 -1
View File
@@ -1,7 +1,10 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import path from "path";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ turbopack: {
root: path.join(__dirname),
},
}; };
export default nextConfig; export default nextConfig;
+1
View File
@@ -11,6 +11,7 @@
| Concern | Rule | | Concern | Rule |
|---|---| |---|---|
| Framework | Next.js **App Router** + **TypeScript**. Work within the existing structure; do not restructure or introduce a competing router. | | Framework | Next.js **App Router** + **TypeScript**. Work within the existing structure; do not restructure or introduce a competing router. |
| Styling / UI | **Tailwind CSS** for all styling — no CSS modules, styled-components, or inline style objects. UI primitives come from **shadcn/ui** (`components/ui/`, built on `@base-ui/react` + `class-variance-authority`); use/extend existing components there before adding a new one, and generate new primitives via the shadcn CLI to keep the pattern consistent. |
| State / forms | **Plain React hooks** (`useState`, `useReducer`, custom hooks). No form/state library. | | State / forms | **Plain React hooks** (`useState`, `useReducer`, custom hooks). No form/state library. |
| Validation | **Dependency-free** (hand-rolled helpers). See §3. | | Validation | **Dependency-free** (hand-rolled helpers). See §3. |
| API access | A single **typed fetch client** (`lib/`) against `NEXT_PUBLIC_API_BASE_URL`; all calls go through it. No scattered `fetch()` in components. | | API access | A single **typed fetch client** (`lib/`) against `NEXT_PUBLIC_API_BASE_URL`; all calls go through it. No scattered `fetch()` in components. |