Cheat sheet · Frontend · Next.js App

สร้างแอปแรกด้วย Next.js — โครงโฟลเดอร์ · shadcn · tokens · CVA · layout แผ่นเดียวจบ

คู่กับ บทเรียน 79–85 · ทุกผลรันในหน้านี้ก็อปมาจากบทเรียน ซึ่งรันบนโปรเจกต์ Next.js 16.3.5 + shadcn 4.21.0 ตัวจริง

หน้านี้ไม่ได้เขียนไว้ให้อ่านรวดเดียว — เขียนไว้ให้เปิดตอนมือติดคีย์บอร์ด
ถ้ากำลังรีบ: หัวข้อ 9 (ตารางแปล error) และหัวข้อ 10 (ตารางกับดักรวม) คือสองอันที่จะได้ใช้บ่อยที่สุด
ถ้าเพิ่ม class แล้วไม่มีอะไรเกิดขึ้นเลย (build ผ่าน แต่สีไม่ออก) ให้ไปหัวข้อ 4 ก่อน — เกือบทุกครั้งคือ token ยังไม่ได้ลงทะเบียนใน @theme inline
ถ้าจะก็อปโค้ดจากสไลด์ ให้ดูหัวข้อ 2 กับ 6 ก่อน — มีคำสั่งที่พิมพ์ผิดและ component ที่ไม่มีใครเขียนให้

🧰 ตรวจด้วยอะไร เวอร์ชันไหน — ทุกบรรทัดผลรันในหน้านี้มาจาก

next                      16.3.5    # Turbopack
react / react-dom         19.2.8
typescript                5.9.3
tailwindcss               4.3.3
shadcn                    4.21.0    # npx shadcn@latest --version
@base-ui/react            1.8.0
class-variance-authority  0.7.1
cn                        0.3.0     # ตัวที่ทำ cn() — ไม่ใช่ clsx + tailwind-merge แล้ว
eslint                    9.39.5
node                      v24.14.0
npm                       11.9.0

# โปรเจกต์ตั้งต้นของทุกบท:
create-next-app  →  npx shadcn init (preset base-nova)  →  npx shadcn add button card badge skeleton

โค้ดที่ไม่ได้ติดป้ายว่า “พัง” ผ่านครบสามด่าน: npx next build · npx tsc --noEmit · npx eslint . ส่วนเรื่องที่ต้องดูด้วยตา (สี · ตำแหน่ง · เมนูหาย) วัดในเบราว์เซอร์จริงด้วย getComputedStyle() / getBoundingClientRect() · ถ้าเวอร์ชันบนเครื่องคุณไม่ตรง ข้อความและหน้าตาไฟล์อาจต่างกันได้จริง (โดยเฉพาะ shadcn) — ให้เชื่อผลรันบนเครื่องคุณ

สารบัญ

  1. โครงโฟลเดอร์ — อะไรอยู่ไหน ทำไม
  2. คำสั่ง shadcn ที่ใช้จริง
  3. cn() — ตัวรวม class
  4. design tokens สองชั้น
  5. cva — variants ของปุ่ม
  6. Layout · Container · Navbar
  7. RecipeCard + 4 สถานะของหน้า
  8. คำสั่งพิสูจน์ว่าทำงานจริง
  9. ตารางแปล error ที่เจอจริง
  10. ตารางกับดักรวมทั้งกลุ่ม
  11. อ้างอิง

1 · โครงโฟลเดอร์ — อะไรอยู่ไหน ทำไม

.
├── app/
│   ├── layout.tsx            # กรอบที่ห่อทุกหน้า — วาง Navbar / Footer ที่นี่
│   ├── page.tsx              # หน้า /
│   ├── globals.css           # Tailwind + token ทั้งชุด (shadcn init เติมให้)
│   ├── _components/          # ของที่ใช้หน้าเดียว · ขีดล่าง = ไม่เป็น URL
│   ├── _containers/
│   └── recipes/page.tsx      # หน้า /recipes
├── components/
│   ├── RecipeCard/           # หนึ่งโฟลเดอร์ต่อหนึ่งตัว
│   │   ├── RecipeCard.tsx
│   │   └── index.ts          # barrel
│   └── ui/                   # ไฟล์ที่ shadcn add ก็อปมา — อย่าแก้ตรง ๆ
├── containers/
│   └── RecipeList/
│       ├── RecipeList.tsx
│       └── index.ts
├── lib/utils.ts              # export { cn } from "cn"  (บรรทัดเดียว)
└── components.json           # ตั้งค่าที่ shadcn CLI อ่านทุกครั้งที่ add
ที่อยู่ใส่อะไรทำไม · ที่ต้องรู้
app/ หน้าจอแต่ละ URL (page.tsx) · กรอบร่วม (layout.tsx) · globals.css โฟลเดอร์ใต้ app/ ที่มี page.tsx = URL ทันที · ไม่มี page.tsx = 404
app/_components · app/_containers ของที่ใช้หน้าเดียว เช่นแบนเนอร์หน้าแรก ขีดล่าง = Next.js ข้ามทั้งโฟลเดอร์ แม้มี page.tsx ข้างใน (/_components ได้ 404 จริง) · แต่ยัง import ได้ — และ import ข้ามหน้าก็ build ผ่าน (กฎ “ใช้หน้าเดียว” เป็นข้อตกลง ไม่ใช่ที่ Next.js บังคับ)
components/ pure · presentational — รับ props แล้ววาดอย่างเดียว ใช้ได้หลายหน้า ไม่มี state ของงาน ไม่รู้ว่าข้อมูลมาจากไหน · ปกติไม่ต้องมี "use client" (ยกเว้นมี event จริง เช่น onClick — บท 84)
containers/ stateful composition — ถือ state + รู้ business rule แล้วประกอบ component หลายตัว มักต้องมี "use client" เมื่อมี hook จริง · ไม่มี hook / event → ไม่จำเป็น (บท 85 build ผ่านทั้งสองแบบ) · Next.js ไม่รู้จักชื่อ components/ containers/ เลย — วินัยของทีมล้วน ๆ
components/ui/ ไฟล์ที่ npx shadcn@latest add เขียนลงมา (button · card · badge · skeleton · input …) เป็น source ของเราแล้ว แต่ add --overwrite เขียนทับได้เงียบ ๆ → อยากได้แบบใหม่ให้ห่อเป็น component ใหม่ใน components/ (หัวข้อ 2)
lib/utils.ts ที่อยู่ของ cn() shadcn 4.21.0 สร้างเป็น export { cn } from "cn" บรรทัดเดียว · สไลด์และบทความเก่าเป็น clsx + tailwind-merge — คนละรุ่น ไม่มีใครผิด · import { cn } from "@/lib/utils" ยังใช้ได้
components.json สร้างโดย init — style · ไฟล์ CSS · aliases aliases.ui = ที่ add จะวางไฟล์ · tailwind.config ว่าง = Tailwind v4 · ไม่ได้จดรายชื่อ component ที่ติดตั้ง — โฟลเดอร์ components/ui/ คือบันทึกเดียวที่มี

ตัดสินว่าโค้ดชิ้นนี้อยู่ไหน — ถามสองข้อตามลำดับ

คำถามใช่ไม่
1 · มันจำ state ของงาน หรือรู้ business rule ไหม?containercomponent
2 · ใช้กี่หน้า?หน้าเดียว → app/<หน้า>/_components หรือ _containersหลายหน้า → components/ · containers/ ระดับบนสุด
⚠ สัญลักษณ์ในชื่อโฟลเดอร์ใต้ app/ มีความหมาย

[ชื่อ] = dynamic segment รับค่าอะไรก็ได้ · _ชื่อ = ไม่ใช่ route · สไลด์หน้า 7 วาดโฟลเดอร์ [another-routes]/ ไว้ — ถ้าสร้างจริงทั้งวงเล็บ ทุก URL ชั้นเดียวได้ 200 หมด (curl /recipes ได้ <p>dynamic: <!-- -->recipes</p>) · ตั้งชื่อธรรมดาอย่าง app/recipes/page.tsx แล้วเก็บวงเล็บไว้ใช้ตอนตั้งใจรับค่า เช่น app/recipes/[id]/ · Next.js 16 ส่ง params มาเป็น Promise ต้อง await ก่อน

2 · คำสั่ง shadcn ที่ใช้จริง

อยากทำอะไรพิมพ์ผลจริง · ที่ต้องรู้
เตรียมโปรเจกต์ (ครั้งเดียว) npx shadcn@latest init -t next สไลด์หน้า 11 · ถาม component library (เลือก Base UI) แล้วถาม preset · ไม่ได้ก็อป component ให้สักตัว — สร้าง components.json · แก้ app/globals.css · สร้าง lib/utils.ts · เพิ่ม dependency ใน package.json
ก็อป component มาใส่โปรเจกต์ npx shadcn@latest add input ✔ Created 1 file: - components/ui/input.tsx · git status --short เห็นแค่ ?? components/ui/input.tsx · package.json กับ components.json ไม่เปลี่ยน
ลงหลายตัวพร้อมกัน npx shadcn@latest add button card badge skeleton ชุดที่โปรเจกต์ตรวจของทุกบทใช้
ดูก่อนว่าจะเขียนทับอะไร npx shadcn@latest add button --diff components/ui/button.tsx โชว์ - / + ทีละบรรทัด โดยไม่เขียนไฟล์ · ใช้ทุกครั้งก่อน overwrite
ลองโดยไม่เขียนไฟล์ --dry-run ดู flag ครบด้วย npx shadcn@latest add --help
เขียนทับไม่ถาม npx shadcn@latest add button --overwrite ℹ Updated 1 file · ของที่แก้ไว้หายเกลี้ยง ไม่มีคำเตือน ไม่มีไฟล์ .bak · git status ว่างถ้าไฟล์กลับไปเหมือน commit เดิม
เช็คเวอร์ชัน npx shadcn@latest --version ไฟล์ที่ได้ไม่ตรงสไลด์/เพื่อน → ถามเรื่องนี้ก่อน ส่วนใหญ่เป็นเรื่องรุ่น
⚠ สไลด์หน้า 14 กล่องแรกพิมพ์ผิด — ขาดตัว x
$ npm shadcn@latest add button        # ← ตามสไลด์ ใช้ไม่ได้
Unknown command: "shadcn@latest"

To see a list of supported npm commands, run:
  npm help

$ npx shadcn@latest add button        # ← ที่ถูก (เหมือน init ในหน้า 11)

npm ต้องการชื่อคำสั่งย่อย (install · run) · npx ต้องการชื่อโปรแกรม · pnpm dlx / yarn dlx ในสองกล่องล่างของสไลด์คือ npx ของค่ายอื่น ถูกทั้งคู่ · npm ตอบ Unknown command แล้วจบ ไม่มีไฟล์ไหนเปลี่ยน

สั่ง add ซ้ำกับไฟล์ที่มีอยู่แล้ว — ได้ข้อความไม่เหมือนกัน

สถานการณ์ข้อความที่เห็นจริงไฟล์
ไฟล์มีอยู่ ไม่ได้แก้ ℹ Skipped 1 file: (files might be identical, use --overwrite to overwrite)ไม่เปลี่ยน · แบบในภาพสไลด์หน้า 14
ไฟล์มีอยู่ แก้ไว้แล้ว ? The file button.tsx already exists. Would you like to overwrite? › (y/N)ถามครั้งเดียว · ใส่ --yes ก็ยังถาม
ใส่ --overwrite ℹ Updated 1 file: - components/ui/button.tsxกลับเป็นต้นฉบับ งานที่แก้หาย

“อย่าแก้ไฟล์ตรง ๆ ให้สร้างใหม่ตามแบบ shadcn” (สไลด์หน้า 16) — ห่อแทนแก้

// components/ActionButton/ActionButton.tsx
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type ActionButtonProps = ComponentProps<typeof Button> & {   // รับทุก prop ที่ Button รับ + ของเรา
  loading?: boolean;
};

const ActionButton = ({ loading = false, disabled, className, children, ...restProps }: ActionButtonProps) => {
  return (
    <Button
      className={cn("bg-orange-500 text-white hover:bg-orange-600", className)}  // className คนเรียกไว้ท้าย → ทับได้
      disabled={disabled || loading}
      {...restProps}
    >
      {loading ? "กำลังบันทึก..." : children}
    </Button>
  );
};

export default ActionButton;

// components/ActionButton/index.ts
export { default } from "./ActionButton";

พิสูจน์แล้ว: สั่ง add button --overwrite หลังมี ActionButton → button.tsx กลับเป็นต้นฉบับ แต่ HTML ของ ActionButton ยังลงท้าย bg-orange-500 text-white hover:bg-orange-600 ครบ · อยากตัด children ออกจาก type ใช้ Omit<ComponentProps<typeof Badge>, "children"> (ภารกิจ DifficultyBadge บท 80)

3 · cn() — ตัวรวม class ที่รู้ว่า class ไหนชนกัน

ปัญหาที่มันแก้: ต่อสตริง "px-4 px-2" เองแล้วหวังว่าตัวหลังชนะ — วัดจริงได้ padding 16px (px-4 ชนะ) เพราะ CSS ตัดสินจากลำดับ rule ในไฟล์ CSS ที่ Tailwind เรียงเอง (.px-2 มาก่อน .px-4) ไม่ใช่ลำดับใน attribute class

เรียกผลรันจริงอ่านว่า
cn("px-2", "px-4")"px-4"เรื่องเดียวกัน ตัวหลังชนะ
cn("px-2 py-1", "p-4")"p-4"p-4 คุมสี่ด้าน มาทีหลังจึงกลืน px/py
cn("p-4", "px-2")"p-4 px-2"px-2 ทับได้แค่ซ้ายขวา · p-4 ยังต้องอยู่
cn("text-red-500", "text-lg")"text-red-500 text-lg"สีกับขนาด คนละเรื่อง เก็บทั้งคู่
cn("rounded-lg", isActive && "bg-blue-500", { "text-white": isActive, "border": true }) · isActive = false"rounded-lg border"รับ string · false (ทิ้ง) · object { class: เงื่อนไข }
"px-2" + " " + "px-4""px-2 px-4"ต่อสตริงเอง ไม่ตัดอะไร
cn("text-lg", "text-brand")"text-lg text-brand"class ที่ตั้งเอง cn ไม่รู้ว่าชนกับอะไร — เก็บทั้งคู่
cn(Container ของบท 83, "px-8 flex h-16")"mx-auto w-full max-w-7xl sm:px-6 lg:px-8 px-8 flex h-16"px-4 หาย · sm:px-6 อยู่ต่อ (คนละเงื่อนไข)
cn(buttonVariants({ size: "sm" }), "h-12 bg-accent")"… px-3 text-sm text-primary-foreground hover:bg-primary/90 h-12 bg-accent"h-8.5 กับ bg-primary หาย · hover:bg-primary/90 อยู่ (คนละเงื่อนไข)

กติกาที่ใช้ได้ทุก component ที่รับ className: cn(class ของเรา, className) — ใส่ของคนเรียกไว้ท้าย คนใช้จะทับได้ · ต่อสตริงเองหรือส่ง className ให้ cva ตรง ๆ โดยไม่ผ่าน cn → มีทั้ง h-8.5 และ h-12 อยู่ด้วยกัน ผลขึ้นกับลำดับไฟล์ CSS ทายจากโค้ดไม่ได้

4 · design tokens — ค่าจริงใน :root → ชื่อใน @theme inline → class

:root          { --primary: #a80689 }                  ← ชั้น 1: ค่าจริง (browser อ่าน)
      │
      ▼
@theme inline  { --color-primary: var(--primary) }     ← ชั้น 2: ตั้งชื่อให้ Tailwind
      │                 └──┬──┘
      │         "--color-" + "primary"
      ▼
class ที่ใช้ได้:  bg-primary · text-primary · border-primary · ring-primary · hover:bg-primary …
      │
      ▼  (npx next build)
CSS จริงที่ได้:  .bg-primary{background-color:var(--primary)}
ชั้น 1 (:root · .dark)ชั้น 2 (@theme inline)class ที่ได้
--primary--color-primary: var(--primary)bg-primary · text-primary · border-primary
--primary-foreground--color-primary-foregroundtext-primary-foreground
--border--color-borderborder-border
--radius--radius-lg: var(--radius)rounded-lg (ตระกูล --radius- ไม่ใช่ --color-)
--primary-hover อยู่ใน :root อย่างเดียว— ไม่มี —ไม่มี class · build ผ่าน · ไม่มี warning

ขั้นตอนเพิ่ม token ใหม่ (ทำครบทุกข้อ)

  1. ใส่ค่าจริงใน :root — และใน .dark ถ้ามีธีมมืด
  2. ลงทะเบียน --color-ชื่อ: var(--ชื่อ); ในบล็อก @theme inline เดิม ของ shadcn — อย่าเปิดบล็อก @theme ใหม่
  3. ใช้ใน JSX เช่น bg-accent-subtle · text-accent-strong
  4. npx next build แล้ว grep CSS ที่ build ออกมา ให้เจอทั้ง rule และค่า (หัวข้อ 8) — “เปิดหน้าแล้วสวย” ไม่นับว่าพิสูจน์
/* app/globals.css — ผลจาก git diff จริงของภารกิจบท 81 (ตัดบริบทออก) */
@theme inline {
  --color-accent-subtle: var(--accent-subtle);
  --color-accent-strong: var(--accent-strong);
}
:root {
  --accent-subtle: #fff1dc;
  --accent-strong: #b36a00;
}
@utility wongnok-text-h1 {
  font-size: 2.25rem;
  line-height: 2.5rem;
  font-weight: 700;
}
ลงทะเบียนแบบCSS ที่ build ได้ใต้ <div class="dark"> (วัดจริง)
@theme inline { --color-primary-hover: var(--primary-hover) } .bg-primary-hover{background-color:var(--primary-hover)} rgb(232, 121, 201) = ค่าใน .dark ✅
@theme { --color-brand-noinline: var(--primary-hover) } .bg-brand-noinline{background-color:var(--color-brand-noinline)} + ตัวแปรถูกคำนวณที่ :root rgb(138, 5, 112) = ค่าธีมสว่าง ❌

แบบไม่ inline จะ “ดูเหมือนใช้ได้” ตอนใส่ dark ที่ <html> (ได้ rgb(232, 121, 201) ทั้งสองแบบ) แล้วค่อยพังตอนมีคนทำกล่องธีมมืดเฉพาะส่วน · dark: ในโปรเจกต์นี้ผูกกับ class ด้วย @custom-variant dark (&:is(.dark *));

class ของเราเอง (เช่น wongnok-text-h2) — ใช้ @utility

เขียนแบบwongnok-…md:wongnok-…
class ธรรมดาใน @layer components { .wongnok-plain-h2 {…} }ได้ rule ✅ไม่มี rule เลย (grep นับได้ 0) ❌
@utility wongnok-text-h2 {…}ได้ rule ✅ได้ใน @media (min-width:48rem) ✅ · ใช้กับ hover: dark: ได้ · ไม่มีใครใช้ = ไม่ใส่ลงไฟล์
⚠ ก่อนก็อป token จากสไลด์หน้า 25–27

5 · cva — ปุ่มหลายหน้าตาในที่เดียว

แพ็กเกจชื่อ class-variance-authority (หัวสไลด์หน้า 31 สะกด “Variant” — ตอนค้นเอกสาร/พิมพ์ import ใช้ variance) · shadcn init ติดตั้งให้แล้ว · cva ไม่สร้าง CSS มันแค่ประกอบ string class — Tailwind ยังเป็นคนสร้าง CSS

// components/Bases/button-variants.ts (ย่อ — ไฟล์เต็มอยู่บท 82 หัวข้อ 3)
import { cva, type VariantProps } from "class-variance-authority";

export const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",  // ① class ฐาน
  {
    variants: {                                         // ② เมนูแต่ละแกน
      variant: { contained: "", outlined: "border-2 bg-transparent" },
      color:   { primary: "", accent: "", error: "", gray: "" },   // ว่าง: สีขึ้นกับ variant ด้วย
      size:    { sm: "h-8.5 px-3 text-sm", md: "h-10 px-4 text-sm", lg: "h-12 px-6 text-base" },
    },
    compoundVariants: [                                 // ③ ต่อท้ายเมื่อทุกเงื่อนไขตรง
      { variant: "contained", color: "primary", className: "bg-primary text-primary-foreground hover:bg-primary/90" },
      { variant: "outlined",  color: "primary", className: "border-primary text-primary hover:bg-primary/10" },
      // … ครบ 8 คู่ (2 variant × 4 color)
    ],
    defaultVariants: { variant: "contained", color: "primary", size: "md" },   // ④ ใช้เมื่อเป็น undefined
  },
);

export type ButtonVariantProps = VariantProps<typeof buttonVariants>;   // ⑤ type มาจากเมนูเอง
ส่วนทำอะไรผลจริงที่ต้องจำ
ลำดับใน stringclass ฐาน → variant → color → size → compoundVariants ท้ายสุดstring ว่างไม่ทิ้งช่องว่างซ้อน
variantsเมนูแต่ละแกนที่เป็นอิสระต่อกัน2 × 4 × 3 = 24 หน้าตา โดยไม่ต้องตั้งชื่อก้อนอย่าง destructive-outline · แกน boolean ได้: block: { true: "w-full", false: "" }
compoundVariantsต่อ class เมื่อทุกช่องในกฎตรงพร้อมกัน (นับค่าจาก default ด้วย)ไม่มีกฎที่ตรง = ไม่ต่อ ไม่มี error · compound ไม่ลบ class ของแกนอื่น (ใส่ border-3 แล้ว string มีทั้ง border-2 และ border-3 — cn ค่อยตัดทิ้ง)
defaultVariantsค่าเมื่อแกนนั้นเป็น undefinednull = “ไม่เอาค่าไหนเลย” → ไม่ใช้ default
VariantProps<typeof …>สร้าง type ของ props จากเมนูเพิ่มค่าในเมนู type เปลี่ยนตาม · ส่งค่าผิดได้ TS2322 พร้อมรายการค่าที่ถูก
disabledไม่ต้องมีแกนของตัวเองdisabled:opacity-50 disabled:pointer-events-none ในคลาสฐาน ทำงานเมื่อแท็กมี attribute disabled จริง

compound ต่อท้ายเมื่อไหร่ — cva ย่อ ("btn" + สองกฎของสไลด์หน้า 32 ที่ต้องการ contained)

เรียกผลรันจริงเพราะ
{ variant: "contained", color: "primary" }"btn bg-primary text-primary-foreground hover:bg-primary/90"ครบสองเงื่อนไข
{ variant: "outlined", color: "primary" }"btn border-2"variant ไม่ตรง → ได้แค่ของแกน variant
{ variant: "contained", color: "error" }"btn"ไม่มีกฎของคู่นี้ · ไม่เตือน
{ color: "accent" }"btn bg-accent text-accent-foreground hover:bg-accent/90"default เติม contained ให้ → ครบ
{ variant: undefined }"btn bg-primary text-primary-foreground hover:bg-primary/90"undefined = ใช้ default
{ variant: null }"btn"null ปิด default — ข้อมูลจาก API ที่ส่ง null มา ปุ่มเสียสีเงียบ ๆ

ห่อเป็น Button ที่มี loading

// components/Bases/Button.tsx
import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
import { buttonVariants, type ButtonVariantProps } from "./button-variants";

export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
  ButtonVariantProps & {
    loading?: boolean;
  };

const Button = (props: ButtonProps) => {
  const { variant, color, size, loading = false, disabled, className, children, ...restProps } = props;
  // ↑ ดึงของตัวเองออกให้ครบก่อน ...restProps ไม่งั้นไหลไปเป็น attribute บน <button>

  return (
    <button
      className={cn(buttonVariants({ variant, color, size }), className)}
      disabled={disabled || loading}                  // กันกดซ้ำ + จางให้ฟรี
      aria-busy={loading || undefined}                // ปุ่มปกติจะไม่มี attribute นี้เลย
      {...restProps}
    >
      {loading && <Loader2Icon className="size-4 animate-spin" aria-hidden="true" />}
      {children}
    </button>
  );
};

export default Button;
เรื่องButton ของเรา (craft version หน้า 30–32)components/ui/button.tsx ของ shadcn
แกนvariant × color × size (2 × 4 × 3) · md = h-10 = 40pxvariant × size (6 × 8) · ไม่มี compoundVariants เพราะรวมสีในชื่อ (destructive) · default h-8 = 32px
typeButtonHTMLAttributes & VariantProps<typeof buttonVariants>ButtonPrimitive.Props & VariantProps<typeof buttonVariants> (ปุ่มของ @base-ui/react)
รวม classNamecn(buttonVariants({…}), className)cn(buttonVariants({ variant, size, className })) — ผลเท่ากัน
exportexport default Buttonexport { Button, buttonVariants } — เอา class ปุ่มไปแปะบน <a> ได้

ขนาดตามป้าย 34 / 40 / 48px ในสไลด์หน้า 30 ใช้ h-8.5 / h-10 / h-12 ได้เลย ไม่ต้อง h-[34px] — CSS จริงคือ .h-8\.5{height:calc(var(--spacing) * 8.5)} กับ --spacing:.25rem → 2.125rem = 34px · ชื่อในสไลด์ไม่ตรงกันเอง: หน้า 29 ซ้ายเขียน outlined ขวาเขียน variant="outline" · ชุดหน้า 28–29 เป็นสไตล์ shadcn ส่วนหน้า 30–32 เป็นชุด craft — อย่าก็อปปนกัน

6 · Layout · Container · Navbar

Container — สไลด์หน้า 39 เรียกใช้ แต่ไม่มีหน้าไหนนิยามให้

// components/layout/Container.tsx (บท 83 เขียนเอง — สไลด์ไม่ได้ให้มา · shadcn ก็ไม่มี)
import { cn } from "@/lib/utils";

export function Container({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      className={cn("mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8", className)}
      {...props}
    />
  );
}
classCSS จริงหลัง buildหน้าที่
max-w-7xlmax-width:var(--container-7xl) · --container-7xl:80remกว้างไม่เกิน 1280px · จอแคบกว่านั้นกว้างเท่าจอ (w-full)
mx-automargin-inline:autoอยู่กลางจอ → ขอบซ้ายของโลโก้ · เนื้อหา · footer ตรงกัน
px-4 sm:px-6 lg:px-8padding-inline:calc(var(--spacing) * 4)ตัวหนังสือไม่ชิดขอบจอ · ขอบกว้างขึ้นตามจอ

พื้นหลังเต็มจอต้องใส่ที่กล่องแม่ชั้นนอก (<header> · <footer>) ไม่ใช่ที่ Container

Navbar กับ active link ที่ถูกต้อง

// components/layout/Navbar.tsx (ตัดส่วนโลโก้ออก)
"use client";                                   // ← บรรทัดแรกสุด เพราะใช้ usePathname

import Link from "next/link";
import { usePathname } from "next/navigation";
import { Container } from "@/components/layout/Container";
import { cn } from "@/lib/utils";

const NAV_LINKS = [
  { href: "/", label: "Home" },                  // ไม่มี active: true ตายตัว
  { href: "/recipes", label: "Recipes" },
];

function Navbar() {
  const pathname = usePathname();
  return (
    <header className="fixed inset-x-0 top-0 z-50 border-b bg-card">
      <Container className="flex h-16 items-center justify-between gap-6">
        <nav aria-label="Main" className="hidden items-center gap-1 lg:flex">
          {NAV_LINKS.map((link) => {
            const active = pathname === link.href;
            return (
              <Link
                key={link.href}
                href={link.href}
                aria-current={active ? "page" : undefined}
                className={cn("rounded-md px-3.5 py-2 font-medium", active ? "text-primary" : "hover:bg-muted")}
              >
                {link.label}
              </Link>
            );
          })}
        </nav>
      </Container>
    </header>
  );
}

export default Navbar;
// app/layout.tsx — ใน <body className="min-h-full flex flex-col"> ที่ create-next-app สร้างให้
<Navbar />
<main className="pt-16">{children}</main>     // เว้นที่ให้ Navbar แบบ fixed
<Footer />                                   // Footer ใช้ className="mt-auto" → ดันลงล่างสุดของจอ
เรื่องผลที่วัด/รันจริงจำไว้ว่า
<Link href> vs <a href> HTML ออกมาเป็น <a href="/"> เหมือนกันเป๊ะ · กด <Link> แล้ว window.__marker ยังอยู่ ("still-here") · กด <a> แล้วหาย ("GONE (page reloaded)") ลิงก์ในแอปใช้ <Link> เสมอ — <a> โหลดใหม่ทั้งหน้า state ใน layout กลับเป็นค่าเริ่มต้น · build ผ่านทั้งคู่ ตัวที่ฟ้องคือ eslint · ลิงก์ไปเว็บข้างนอกใช้ <a> ได้ · <Link> ใช้ใน Server Component ได้
usePathname() + 'use client' ไม่มี "use client" → build พัง ชี้ ./components/layout/Navbar.tsx:2:10 · ใส่แล้ว curl /recipes ได้ aria-current="page" ที่ Recipes ถูกต้อง ใส่ที่ไฟล์ Navbar ไม่ใช่ app/layout.tsx (layout มี export const metadata → พังอีกแบบ) · <header> ยังเป็น element ตัวเดิมหลังเปลี่ยนหน้า (sameHeaderNode: true)
active: true เขียนตายตัว อยู่หน้า /recipes แต่ aria-current="page" ยังอยู่ที่ Home <Link> ไม่ได้ใส่ aria-current ให้เอง · สิ่งที่ขึ้นกับ URL ต้องคำนวณจาก URL
fixed top-0 ไม่มี padding จอ 1280px: header 0–65 · h1 0–24 · elementFromPoint ตรงหัวข้อ = "inside HEADER" fixed หลุดจากลำดับหน้า ไม่กินที่ → หัวข้อแรกทุกหน้าโดนบัง คลิกไม่โดน
fixed + <main className="pt-16"> h1 เริ่มที่ 64 แต่ header จบที่ 65 — ยังบัง 1px 1px มาจาก border-b ของ header (h-16 อยู่ที่ Container ข้างใน) · ต้องวัด ไม่ใช่คิดเลข
sticky top-0 (เอา pt-16 ออก) "headerPos": "sticky" · header จบ 65 · h1 เริ่ม 65 พอดี sticky ยังกินที่ตามปกติ แต่ติดขอบบนตอนเลื่อน · แบบร่างหน้า 36 เขียน “sticky shadow” ส่วนโค้ดหน้า 39 ใช้ fixed
hidden lg:flex 1280px → display: "flex" · 375px → "none" · CSS: @media (min-width:64rem){ .lg\:flex{display:flex} } ต่ำกว่า 1024px เมนูหายหมด ไป Recipes ไม่ได้ · mobile drawer ต้องเขียนเอง (สไลด์มีแค่ในแบบร่าง)
Footer ไม่ลงล่างสุด จอ 1280×800 · มี mt-auto → footer 616–800 · เอาออก → 88–272 mt-auto ดันได้เฉพาะในกล่อง flex — เช็คว่า <body> ยังมี flex flex-col · Footer ไม่มี hook ไม่ต้อง "use client"

7 · RecipeCard + 4 สถานะของหน้า

props type — อ่านจาก design properties (สไลด์หน้า 45–46)

// components/RecipeCard/RecipeCard.tsx (ส่วน type · บท 84)
export type RecipeLevel = "EASY" | "MEDIUM" | "HARD";   // union ไม่ใช่ string

export type RecipeSummary = {
  id: number;
  name: string;
  imageUrl?: string;           // ยังไม่มีรูปจริง → วาด placeholder
  level: RecipeLevel;
  ownerName: string;
  ownerImageUrl?: string;      // เพิ่มในภารกิจท้ายบท · ไม่มีรูป → วงกลมตัวย่อชื่อ
  rating: number;
  ratingCount: number;
};

export type RecipeCardProps = {
  recipe: RecipeSummary;       // ส่งสูตรทั้งก้อน ตามโค้ดสไลด์หน้า 45
  onClick?: (id: number) => void;
};

const levelLabel: Record<RecipeLevel, string> = { EASY: "Easy", MEDIUM: "Medium", HARD: "Hard" };
// ค่าที่เก็บ (EASY) แยกจากข้อความที่โชว์ (Easy)
ในสไลด์fieldต้องมี?เหตุผล
Recipe namename: string✅ไม่มีชื่อ = ไม่รู้ว่าเมนูอะไร
imageimageUrl?: string⭕ดีไซน์ยังเป็นลาย dish photo ทุกใบ · ไม่มีก็วาดกล่องเทา aspect-4/3
levellevel: RecipeLevel✅มีได้แค่สามค่า → union
owner nameownerName: string✅ทุกสูตรมีคนเขียน
rating / rating countrating · ratingCount: number✅สูตรใหม่ก็เป็น 0 ได้ · ลืมใส่ได้ TS2741
onClickonClick?: (id: number) => void⭕เรียกด้วย onClick?.(recipe.id) — ไม่มีก็ข้าม
owner image · Is favorite? · onFavorite—⏸สไลด์หน้า 46 (FOCUSED) ทำให้จาง = รอบนี้ยังไม่ทำ · favorite ต้องมี login ก่อน

4 สถานะ — เก็บเป็น discriminated union บน status

// components/RecipeList/RecipeList.tsx (โครง)
export type RecipeListState =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; recipes: RecipeSummary[] };   // ไม่มี "empty" — ว่าง = success ที่ยาว 0

const RecipeList = ({ state }: { state: RecipeListState }) => {
  if (state.status === "loading") return /* Skeleton 4 กล่อง · aria-busy="true" */;
  if (state.status === "error")   return /* role="alert" · state.message · Try again */;
  if (state.recipes.length === 0) return /* No recipes match your search · Clear filters */;
  return /* state.recipes.map((recipe) => <RecipeCard key={recipe.id} recipe={recipe} />) */;
};
สถานะเกิดตอนไหนไม่ทำไว้ ผู้ใช้เห็นHTML จริงที่ได้ (curl /recipes?state=…)
Defaultข้อมูลมาครบ ≥ 1 สูตร—data-slot="card" 3 ใบ
Loadingรอ API · เน็ตช้าหน้าขาว ไม่รู้ว่าค้างหรือโหลด<div aria-busy="true"> + data-slot="skeleton" 4 กล่อง · การ์ด 0
EmptyAPI สำเร็จแต่ลิสต์ว่าง · ค้นไม่เจอหน้าขาว แยกไม่ออกจาก Loading<h3>No recipes match your search</h3><a href="/recipes">Clear filters</a>
ErrorAPI ล่ม · เน็ตหลุดหน้าขาว หรือพังทั้งหน้า<div role="alert"><h3>Something went wrong</h3><p>Could not load recipes</p>…
ท่าtscผลจริง
{ isLoading: boolean; error?: string; recipes: RecipeSummary[] } แล้วใส่ครบสามอย่างพร้อมกันผ่านimpossible state — หน้าควรโชว์อะไร ไม่มีใครตอบได้
union แล้วเขียน { status: "loading", recipes: mockRecipes }TS2353เขียนผิดไม่ได้ตั้งแต่แรก
union แล้วเพิ่ม | { status: "idle" } แต่ลืม ifTS2339 ชี้บรรทัดที่ใช้ state.recipesบอกทุกจุดที่ต้องแก้
union แต่ลบก้อน if (state.recipes.length === 0)ผ่าน<div class="grid grid-cols-4 gap-4"></div> เปล่า · ลิสต์ว่างเป็นเรื่องของค่า ไม่ใช่ type → ต้องเปิดดูด้วยข้อมูลว่างจริง
level: string + ข้อมูล "EASYY"ผ่าน (exit 0)ป้าย level ว่างเปล่า (levelLabel["EASYY"] = undefined)
level: RecipeLevel + ข้อมูล "EASYY"TS2820 + Did you mean '"EASY"'?ไม่มีทางไปถึงหน้าเว็บ

หน้า All Recipes (บท 85) — responsive + ข้อความหัวจาก state

// app/recipes/page.tsx (เฉลยภารกิจบท 85 · ส่วนสำคัญ)
const subtitle = (state: RecipeListState) => {
  if (state.status === "loading") return "Loading recipes…";
  if (state.status === "error") return "Could not load recipes";
  return `${state.recipes.length} recipes found`;         // แทน "8 recipes · page 1 of 7" ที่ hardcode จาก mock
};

<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-7">   // มือถือ 1 · แท็บเล็ต 2 · จอใหญ่ 4
  <RecipeList state={state} />
</div>

// ใน RecipeList: กล่อง empty / error ใส่ className="col-span-full …" ไม่งั้นอัดอยู่ช่องแรก
// (RecipeList คืน Fragment <> — grid อยู่ที่ page.tsx ตามสไลด์หน้า 49)

sm: = @media (min-width:40rem) = 640px · lg: = @media (min-width:64rem) = 1024px · จอ 375px ได้ 1 คอลัมน์ · 700px ได้ 2 · 1280px ได้ 4 · prefix แปลว่า “ตั้งแต่ขนาดนี้ขึ้นไป” ลำดับใน className ไม่มีผล · ผล curl ของเฉลย: /recipes การ์ด 8 · /recipes-empty การ์ด 0 skeleton 0 + 0 recipes found · /recipes-loading skeleton 8

คำถามคำตอบจากผลรันจริง
RecipeCard ต้องมี "use client" ไหมต้อง ถ้ามี onClick (event เกิดในเบราว์เซอร์) · ไม่มี hook/event ก็ไม่ต้อง
Server Component ส่ง onClick={() => …} ให้ Client Component ได้ไหมไม่ได้ — tsc ผ่าน · build ผ่าน · เปิดหน้าได้ 500 · ฟังก์ชันไม่ serializable
RecipeList (ไม่มี state / event) ต้องมี "use client" ไหมไม่จำเป็น — build ผ่านทั้ง 4 แบบ route เป็น ○ Static ทุกแบบ · การ์ดเป็น server: เอาออกแล้วรวม gzip เล็กลง 12,872 ไบต์ · การ์ดเป็น client: เล็กลงแค่ 243 ไบต์ (mock ย้ายจาก JS ไปอยู่ใน HTML)
เมื่อไหร่ container ต้องมี "use client" จริงตอนเริ่ม stateful จริง เช่นเก็บคำค้นด้วย useState · หรือต้องเป็นคนถือฟังก์ชัน onClick ที่ส่งลงการ์ด
key ใช้อะไรkey={recipe.id} · index ใช้ได้เฉพาะรายการที่ไม่เปลี่ยนลำดับและลูกไม่มี state (เช่น skeleton)

8 · คำสั่งพิสูจน์ว่าทำงานจริง

อยากรู้ว่าพิมพ์ที่ต้องรู้
ผ่านสามด่านnpx next build
npx tsc --noEmit; echo "tsc rc=$?"
npx eslint .; echo "eslint rc=$?"
tsc / eslint ที่ผ่านไม่พิมพ์อะไรเลย ต้องต่อ echo rc=$? · build ผ่าน ≠ ถูก: class ที่ไม่มีจริง · <a> แทน <Link> · ลืม key · onClick ข้ามฝั่ง ผ่าน build ทั้งหมด
class ถูกสร้างจริงไหมgrep -o '\.bg-primary-hover{[^}]*}' .next/static/chunks/*.css; echo "rc=$?" rc=1 = ไม่มี rule → กลับไปดู @theme inline ก่อนอย่างอื่น
ค่า token อยู่ใน CSS ไหมgrep -oE -- '--accent-(subtle|strong):[^;]*' .next/static/chunks/*.css ค่าอยู่แต่ class ไม่มี = ลืมชั้น 2 · class มีแต่ค่าไม่มี = ลืมชั้น 1
breakpoint อยู่ที่เท่าไหร่grep -oE '@media[^@]*lg\\:grid-cols-4' .next/static/chunks/*.css | grep -oE '^@media \([^)]*\)' ได้ @media (min-width:64rem) — ไม่ต้องจำ ดูจากไฟล์ · ใน CSS จริง .lg\:grid-cols-4 อยู่ในบล็อกเดียวกับ class lg: ตัวอื่น (เช่น .lg\:flex{display:flex}) จึงต้องค้นข้าม { ได้
server ส่ง HTML อะไรมาnpx next start แล้ว curl -s http://localhost:3000/recipes นับ data-slot="card" / data-slot="skeleton" · เช็ค aria-current="page"
status codecurl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/_components หน้า dynamic (ƒ) ถูกวาดตอนมีคนขอ — build ผ่านแล้วยังได้ 500 ได้ ต้อง curl ทุกครั้ง
shadcn ทำอะไรกับโปรเจกต์git status --short · git diff --stat commit ให้สะอาดก่อน add · ?? = ไฟล์ใหม่ที่ git ยังไม่รู้จัก
สี / ตำแหน่ง / เมนูหายไหมใน browser: getComputedStyle(el).display · el.getBoundingClientRect() ใช้แทนการกะด้วยตา — 1px ที่ header บังหัวข้อ จับได้ด้วยวิธีนี้เท่านั้น

9 · ตารางแปล error ที่เจอจริงในบท 79–85

Next.js / Turbopack — ตอน next build หรือเปิดหน้า

ข้อความแปลว่า · ทางแก้
You're importing a module that depends on `useState` into a React Server Component module. This API is only available in Client Components.
(หรือ `usePathname`)
ไฟล์ใช้ hook แต่ไม่มี "use client" · ใส่เป็นบรรทัดแรกสุดของไฟล์ที่ path ใต้ Turbopack build failed ชี้ (บรรทัดบนของ Import trace = ไฟล์ที่ต้องแก้ · บรรทัดล่าง = คนที่เรียกมันมา) · เจอใน container (บท 79) และ Navbar (บท 83)
You are attempting to export "metadata" from a component marked with "use client", which is disallowed. ไปใส่ "use client" ที่ app/layout.tsx · เอาออก แล้วใส่ที่ไฟล์ที่ใช้ hook จริง (Navbar)
⨯ Error: Event handlers cannot be passed to Client Component props.
<... recipe={{...}} onClick={function onClick}>
Server Component ส่งฟังก์ชันให้ Client Component · tsc และ build ผ่าน แต่หน้าได้ 500 (เห็นใน terminal ของ next dev) · ให้ component ที่ถือฟังก์ชันเป็น Client ด้วย
Each child in a list should have a unique "key" prop.
Check the render method of `RecipeList`.
ลืม key ใน .map() · build ผ่าน หน้าได้ 200 · ขึ้นใน log ของ next dev ก่อนบรรทัด GET ของหน้านั้น · เวอร์ชัน client บอกชื่อ component ให้ ส่วนเวอร์ชัน server ไม่บอก

npm / shadcn

ข้อความแปลว่า · ทางแก้
Unknown command: "shadcn@latest"พิมพ์ npm ตามสไลด์หน้า 14 · แก้เป็น npx shadcn@latest …
? The file button.tsx already exists. Would you like to overwrite? › (y/N)ไม่ใช่ error — ไฟล์ถูกแก้ไว้แล้ว · ตอบ y = งานที่แก้หาย · ดู --diff ก่อน

TypeScript — จาก npx tsc --noEmit

โค้ดข้อความ · แปลว่าอะไร
TS2305Module '"shadcn"' has no exported member 'Button'. — package shadcn คือ CLI ไม่มี component · import จาก @/components/ui/button
TS2304Cannot find name 'LayoutProps'. — โปรเจกต์ใหม่ยังไม่เคย build · type นี้ Next.js 16 สร้างให้ตอน next dev/next build · รัน npx next build หนึ่งรอบแล้ว tsc ใหม่ ไม่ต้องแก้โค้ด
TS2322Type '"ghost"' is not assignable to type '"contained" | "outlined" | null | undefined'. — ส่ง variant ที่ไม่มีในเมนู cva (ก็อปชื่อชุด shadcn หน้า 28 มาใช้กับ craft version) · error บอกค่าที่ถูกมาให้แล้ว
TS2322Type '"red"' is not assignable to type '"primary" | "accent" | "error" | "gray" | undefined'. — สังเกตว่า ไม่มี null เพราะชนกับ attribute HTML color?: string ของ ButtonHTMLAttributes
TS2322Type 'string' is not assignable to type 'boolean | null | undefined'. — เขียน <Button block="yes"> กับแกน boolean · ใช้ block หรือ block={true}
TS2322Type '"spicy"' is not assignable to type 'Difficulty'. — ส่งค่านอก union · ตัวอย่างว่า type กันพลาดได้จริง
TS2322Property 'favorited' does not exist on type 'IntrinsicAttributes & RecipeCardProps'. — ส่ง prop ที่ไม่ได้ประกาศ · แต่ชื่อที่มีขีด - (เช่น sc-camel-on-toggle-favorite) TypeScript ไม่ตรวจ
TS2741Property 'ratingCount' is missing in type '{ … }' but required in type 'RecipeSummary'. — ข้อมูลขาดช่องบังคับ · ใส่ให้ครบ หรือทำ field เป็น ? ถ้ามันไม่มีจริง
TS2820Type '"EASYY"' is not assignable to type 'RecipeLevel'. Did you mean '"EASY"'? — พิมพ์ผิดในข้อมูล · ได้เพราะใช้ union (ถ้าเป็น string จะไม่ฟ้อง)
TS7053Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Record<RecipeLevel, string>'. — เปลี่ยน level กลับเป็น string แต่ levelLabel ยังเป็น Record<RecipeLevel, …> · กลับไปใช้ union
TS2353Object literal may only specify known properties, and 'recipes' does not exist in type '{ status: "loading"; }'. — ใส่ข้อมูลในสถานะที่ไม่มีช่องให้ใส่ · union ทำงานถูกแล้ว
TS2339Property 'recipes' does not exist on type '{ status: "idle"; } | { status: "success"; … }'. — เพิ่มสถานะใหม่ในรายการ union แต่ยังไม่มี if รองรับ · เพิ่ม if ก่อนบรรทัดที่ใช้ state.recipes
TS7006Parameter 'recipe' implicitly has an 'any' type. — ผลพ่วงจาก TS2339 ข้างบน (map บน type ที่ไม่รู้จัก) · แก้ตัวบนแล้วหายเอง

ESLint — npx eslint .

ข้อความ · กฎแปลว่า · ทางแก้
Do not use an `<a>` element to navigate to `/`. Use `<Link />` from `next/link` instead.
@next/next/no-html-link-for-pages
ลิงก์ในแอปใช้ <a> · เปลี่ยนเป็น <Link> · next build ไม่ฟ้อง
Missing "key" prop for element in iterator
react/jsx-key
ลืม key ใน .map() · จับไม่ได้ว่าใช้ index เป็น key
@next/next/no-img-element ใช้ <img> แทน <Image> · บท 84 ปิดด้วย // eslint-disable-next-line @next/next/no-img-element เพราะยังไม่มีรูปจริงให้ตั้งค่า

10 · ตารางกับดักรวมทั้งกลุ่ม

อาการที่เห็นสาเหตุทางแก้
ทุก URL ชั้นเดียวได้ 200 · ไม่มี 404 อีกเลย สร้างโฟลเดอร์ app/[another-routes]/ ตามรูปต้นไม้สไลด์หน้า 7 จริงทั้งวงเล็บ ตั้งชื่อธรรมดา (app/recipes/) · วงเล็บใช้เมื่อตั้งใจรับค่า
ได้ /_components เป็น 200 พิมพ์ขีดกลาง - แทนขีดล่าง · หรือมีโฟลเดอร์ [ชื่อ] อยู่ข้าง ๆ รับไปแทน เช็คสองอย่างนี้ · ขีดล่างถูกต้องได้ 404 แม้มี page.tsx
ใส่ useState ใน components/ หรือ import ของ app/_components ข้ามหน้า — build ผ่านสบาย components/containers/_components เป็นข้อตกลงของทีม ไม่มีเครื่องมือตรวจ รักษาด้วยวินัย · ไฟล์ที่เริ่มต้องการ "use client" เพราะ state ของงาน = ถามว่าควรย้ายบ้านหรือยัง
lib/utils.ts / button.tsx หน้าตาไม่เหมือนสไลด์หน้า 15 shadcn 4.21.0 ใช้ export { cn } from "cn" · button ได้ rounded-lg + import { cn } from "cn" (สไลด์เป็น rounded-4xl + @/lib/utils) ไม่ต้องแก้ — คนละรุ่น · เช็คด้วย npx shadcn@latest --version
ปุ่มที่แก้สีไว้ใน components/ui/button.tsx กลับเป็นสีเดิม มีคน (หรือ AI) สั่ง add button --overwrite อย่าเก็บของที่อยากคงไว้ใน components/ui/ — ห่อเป็น component ใหม่ · ดู --diff ก่อน overwrite
ส่ง className="px-2" ต่อท้าย px-4 แล้วไม่ติด (ได้ 16px) ต่อสตริงเอง · CSS ตัดสินจากลำดับ rule ในไฟล์ ไม่ใช่ลำดับใน class รวมด้วย cn(ของเรา, className) เสมอ
ผ่าน cn() แล้ว class ที่ตั้งเองยังชนกันอยู่ cn รู้จักแค่ชื่อ class ของ Tailwind (cn("text-lg", "text-brand") เก็บทั้งคู่) ทำ class ของเราเป็น token/utility ของ Tailwind หรือเลี่ยงไม่ให้ชน
เพิ่มสีใน globals.css แล้วปุ่มไม่มีสี · build ขึ้น ✓ Compiled successfully ใส่แค่ :root ไม่ได้ลงทะเบียน --color-… ใน @theme inline · Tailwind ข้าม class ที่ไม่รู้จักเงียบ ๆ เพิ่มชั้น 2 แล้ว grep CSS ที่ build (หัวข้อ 8) · ติดตั้ง Tailwind CSS IntelliSense ช่วยจับชื่อผิด
ก็อป Navbar สไลด์หน้า 39 แล้วเส้นขอบเทาธรรมดา · โลโก้ไม่ไล่สี · ตัวหนังสือไม่มีสไตล์ wongnok-text-base wongnok-text-sm wongnok-text-h3 border-border-subtle to-primary-hover = NOT FOUND ใน CSS ประกาศ token ของ Wongnok ก่อน: สี 2 ชั้น · wongnok-text-* ด้วย @utility
สีไม่เปลี่ยนตามกล่อง .dark ที่อยู่ข้างในหน้า (แต่สลับทั้งหน้าได้) ลงทะเบียนใน @theme ไม่มี inline → ตัวแปรถูกคำนวณเสร็จที่ :root ใส่ใน @theme inline บล็อกเดิมของ shadcn
md:wongnok-plain-h2 ไม่มีผล แต่ wongnok-plain-h2 มีผล เขียนเป็น class ธรรมดาใน @layer components — variant ไม่ได้ rule เปลี่ยนเป็น @utility wongnok-plain-h2 { … }
component ที่ add มาทีหลังเปลี่ยนเป็นสีส้มแปลก ๆ สไลด์เปลี่ยนความหมาย --accent ของ shadcn grep -rn accent components/ui ก่อนเปลี่ยน token เดิมทุกครั้ง
ปุ่ม outlined ไม่มีขอบสี · ได้ class bg-transparentborder-destructive ต่อ className ด้วย + แล้วลืมเว้นวรรค · tsc ไม่ฟ้อง (เป็น string ถูก type) · พังแค่ 4 ใน 24 หน้าตา ใช้ cva() แทน ternary ซ้อน
ปุ่มบางแบบได้แค่ class ฐาน ไม่มีสีเลย ไม่มีกฎ compoundVariants ของคู่นั้น · หรือค่าเป็น null (ปิด default) เขียนกฎให้ครบทุกคู่ · แปลง null จาก API เป็น undefined ก่อนส่งเข้า cva
<button color="primary"> โผล่ใน DOM จริง ลืมดึง color ออกจาก props · มันผ่าน type เพราะ HTML มี attribute color อยู่แล้ว destructure variant color size loading ออกให้ครบก่อน ...restProps
string มีทั้ง h-8.5 กับ h-12 / border-2 กับ border-3 · ขนาดจริงทายไม่ได้ ส่ง className ให้ cva ตรง ๆ หรือต่อสตริง · compound ไม่ลบ class แกนอื่น ห่อ buttonVariants(…) ด้วย cn() ทุกครั้ง
ผู้ใช้กด “บันทึก” สามครั้ง ส่งซ้ำสามรอบ loading โชว์ spinner แต่ไม่ได้ใส่ disabled disabled={disabled || loading} + spinner + aria-busy
ก็อปโค้ดสไลด์หน้า 39 แล้วหา Container ไม่เจอ สไลด์หน้า 33–40 ไม่มีหน้าไหนนิยาม Container · shadcn ก็ไม่มี เขียนเอง (หัวข้อ 6) · link.active ในสไลด์ก็ไม่มีที่มาเหมือนกัน
หัวข้อแรกทุกหน้าหายไปใต้แถบเมนู / โดนบัง 1px fixed ไม่กินที่ · ใส่ pt-16 (64px) แต่ header สูง 65px เพราะ border-b เปลี่ยนเป็น sticky top-0 แล้วเอา padding ออก · หรือวัดความสูงจริงก่อนตั้ง padding
บนมือถือไม่มีเมนูให้กดเลย hidden lg:flex = ซ่อนทุกจอที่แคบกว่า 1024px · สไลด์ไม่มีโค้ด mobile drawer นับเป็นงานค้าง — ซ่อนอะไรบนมือถือต้องมีทางอื่นให้ไปถึงเสมอ
อยู่หน้า Recipes แต่เมนู Home ยังเป็นสีหลัก · screen reader บอกผิด active: true เขียนตายตัวในอาร์เรย์ usePathname() + "use client" ที่ Navbar · ตั้ง aria-current="page" ตามผลเทียบ
state ใน layout (เช่นคำค้นใน Navbar) หายทุกครั้งที่กดลิงก์ ลิงก์เป็น <a href> ธรรมดา → โหลดใหม่ทั้งหน้า ใช้ <Link> · รัน eslint ด้วย ไม่ใช่แค่ build
ป้าย level ว่างเปล่า ไม่มี error ที่ไหน level: string + ข้อมูลพิมพ์ผิด → levelLabel[…] ได้ undefined · React วาดเป็นความว่าง ประกาศเป็น union "EASY" | "MEDIUM" | "HARD" · (ค่าจาก API ตอนรันจริง TS ยังมองไม่เห็น)
ค้นหาไม่เจอแล้วได้หน้าขาวโล่ง ใต้หัวข้อ All Recipes ไม่มีก้อน Empty · recipes: [] ถูก type สมบูรณ์ tsc จึงไม่ฟ้อง เช็ค state.recipes.length === 0 · เปิดดูด้วยข้อมูลว่างจริงทุกครั้ง
ข้อความ empty/error อัดอยู่ช่องแรกของ grid กล่องเป็นลูกของ grid เหมือนการ์ด ใส่ col-span-full
build ผ่าน แต่เปิดหน้าได้ 500 Server Component ส่ง onClick ให้ Client Component · หน้า dynamic (ƒ) ถูกวาดตอนมีคนขอ ย้ายฟังก์ชันไปอยู่ใน Client Component · หน้า dynamic ต้อง curl ทุกครั้ง
ใส่ prop ชื่อแปลกจากโค้ดสไลด์หน้า 45 (sc-camel-on-toggle-favorite) แล้ว tsc ไม่ว่าอะไร TypeScript ไม่ตรวจชื่อ prop ที่มีขีด - (เปิดทางให้ data-* / aria-*) ใช้ camelCase เช่น onToggleFavorite · อย่าก็อปบรรทัดนั้น
map การ์ดปลอมแล้วหน้าว่าง ไม่มี error เขียน new Array(12).map(…) — ได้ array ที่มีแต่ช่องโหว่ (new Array(3) พิมพ์ออกมาเป็น [ <3 empty items> ]) map ข้ามช่องโหว่ทั้งหมด ใช้ Array.from({ length: 12 }).map((_, index) => …) ตามสไลด์หน้า 48
“8 recipes · page 1 of 7” อยู่เหนือการ์ด 12 ใบ ข้อความจากภาพออกแบบถูกพิมพ์ลงโค้ดตรง ๆ (hardcode) ติดมาถึงสไลด์หน้า 50 คำนวณจาก state: `${state.recipes.length} recipes found` · ตัด page ทิ้งจนมี pagination จริง
บนมือถือการ์ดกว้างไม่ถึง 74px grid-cols-4 ไม่มี prefix = 4 คอลัมน์ทุกจอ grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 (ตรง prompt สไลด์หน้า 23)
กดหัวใจเมนูหนึ่ง ลบ/กรอง/เรียงแล้วหัวใจไปโผล่อีกเมนู · ไม่มี warning key ทำจาก index → state ผูกกับตำแหน่ง · ทั้ง React และ eslint ไม่ฟ้อง key={recipe.id} · ต้องจับเองตอนอ่านโค้ด
ใส่ "use client" ใน container ที่ไม่มี hook เพราะคิดว่า “container ต้องเป็น client” ไม่จำเป็น — ไฟล์นั้นกับทุกอย่างที่ import ถูกส่งเป็น JS ไปเบราว์เซอร์ด้วย ปล่อยเป็น Server จนกว่าจะมี state/event จริง · วัดทั้ง JS และ HTML ก่อนสรุปว่าคุ้ม
หา next.config.js / .env ตามรูปสไลด์ไม่เจอ create-next-app สร้าง next.config.ts และไม่มี .env มาให้ ไม่ต้องทำอะไร · .env สร้างเองระดับบนสุดเมื่อต้องใช้

11 · อ้างอิง