feat(sections): v6-hero, case, services, callout, portfolio, process, pricing, faq (8 components)

- Hero.astro: REPLACED 714-line legacy. v6-hero terminal+stats — 2-col grid
  with $ command + eyebrow + sparkles + 2x2 stats sidebar. All via props.
- CaseStudy.astro: v6-case — image + 3 stats + log + CTAs. Defaults to Dataroot.
- Services.astro: v6-services 2x2 grid. Loads from src/content/services/*-new.mdx
  (4 services). Features hardcoded per title (v7-5 style).
- Callout.astro: v6-callout yellow pullquote.
- Portfolio.astro: v6-portfolio 2-1-1 modal grid. PINNED 3 per plan round 2
  (Dataroot flagship, Luadjob, Jet). First is 'featured' (large).
- Process.astro: v6-process 4-col flow. Hardcoded 4 steps per plan round 2.
- Pricing.astro: v6-pricing. New pricing collection (2 webdev tiers only
  per plan round 2): Astro ฿5,000 featured + WordPress ฿30,000.
  Grid uses auto-fit to gracefully accept 2-3 tiers.
- Faq.astro: v6-faq Q+A list. Loads from src/content/faq/*.md (20 items),
  default limit=4 to match v7-5 demo. Highlights keywords via hardcoded map.

+ src/content/pricing/astro.md + wordpress.md (new)
+ src/content.config.ts: +pricing collection (z.object with features array)

Refs: .hermes/plans/2026-06-13_124000-moreminimore-v7-5-migration.md Task 4.1-4.8
This commit is contained in:
Kunthawat Greethong
2026-06-13 17:54:10 +07:00
parent b586464b5c
commit 154e3f2d91
11 changed files with 634 additions and 695 deletions

View File

@@ -0,0 +1,26 @@
---
/**
* MOREMINIMORE - CALLOUT (from v6-callout · yellow pullquote)
* Extracted from Desktop/moreminomore-mockup-v7-5.html lines 1054-1066
*
* Yellow gradient pullquote with <em> accent.
* Used as a philosophy statement between sections.
*
* Props:
* - text: string (supports <em> for accent)
* - attr?: string (attribution, default '— Moreminimore')
* - id?: string (default: 'callout')
*/
interface Props {
text: string;
attr?: string;
id?: string;
}
const { text, attr = '— Moreminimore', id = 'callout' } = Astro.props;
---
<div id={id} class="fx-callout fx-reveal">
<p class="fx-callout-text" set:html={text} />
<div class="fx-callout-attr">{attr}</div>
</div>

View File

@@ -0,0 +1,102 @@
---
/**
* MOREMINIMORE - CASESTUDY (from v6-case)
* Extracted from Desktop/moreminomore-mockup-v7-5.html lines 873-919
*
* 2-col grid: image (left) + content (right, with stats + log + CTAs)
* Currently used for Dataroot flagship case study only.
* Hardcoded data — case_study body still lives in src/content/portfolio/dataroot.md
*
* Props:
* - client: string (e.g. 'Dataroot')
* - url?: string (link to client site)
* - image?: string (default: dataroot.png from /images/portfolio/)
* - stats: { value, label, coord }[] (default: Dataroot +373/+114/-28)
* - quote: string (large pullquote with <em>)
* - deck: string (subhead under quote)
* - logs: { ts, level, text }[] (default: 3-line timeline)
* - ctaPrimary?: { text, href } (default: อ่านเคสเต็ม → /portfolio)
* - ctaSecondary?: { text, href } (default: ดูผลงานอื่น → /portfolio)
* - id?: string (default: 'case')
*/
interface Stat {
value: string;
label: string;
coord: string;
}
interface Log {
ts: string;
level: 'INFO' | 'SUCCESS' | 'WARN';
text: string;
}
interface CTA {
text: string;
href: string;
}
interface Props {
client: string;
url?: string;
image?: string;
stats: Stat[];
quote: string;
deck: string;
logs: Log[];
ctaPrimary?: CTA;
ctaSecondary?: CTA;
id?: string;
}
const {
client,
url,
image = '/images/portfolio/dataroot.png',
stats,
quote,
deck,
logs,
ctaPrimary = { text: 'อ่านเคสเต็ม →', href: '/portfolio' },
ctaSecondary = { text: 'ดูผลงานอื่น', href: '/portfolio' },
id = 'case',
} = Astro.props;
---
<div id={id} class="fx-case fx-reveal">
<span class="fx-sparkle s2" style="top:12%;right:6%">✦</span>
<span class="fx-sparkle s4" style="bottom:16%;left:4%">◆</span>
<span class="fx-sparkle s5" style="top:50%;right:4%">✦</span>
<div class="fx-case-grid">
<div class="fx-case-image">
<a href={url || ctaPrimary.href}>
<img src={image} alt={client} loading="lazy" />
</a>
</div>
<div class="fx-case-content">
<div class="fx-case-stats">
{stats.map((stat) => (
<div class="fx-case-stat" data-coord={stat.coord}>
<div class="fx-case-stat-num" set:html={stat.value} />
<div class="fx-case-stat-label">{stat.label}</div>
</div>
))}
</div>
<h2 set:html={`"${quote}"`} />
<p class="fx-deck">{deck}</p>
<div class="fx-log fx-stagger">
{logs.map((log) => (
<div>
<span class="ts">{log.ts}</span>{' '}
<span class={log.level === 'INFO' ? 'info' : 'ok'}>{log.level}</span>{' '}
{log.text}
</div>
))}
</div>
<div class="fx-case-cta">
<a href={ctaPrimary.href} class="fx-btn coral">{ctaPrimary.text}</a>
<a href={ctaSecondary.href} class="fx-btn ghost">{ctaSecondary.text}</a>
</div>
</div>
</div>
</div>

88
src/components/Faq.astro Normal file
View File

@@ -0,0 +1,88 @@
---
/**
* MOREMINIMORE - FAQ (from v6-faq · Q+A list)
* Extracted from Desktop/moreminomore-mockup-v7-5.html lines 1381-1409
*
* Q+A list with <em> on keywords in question + answer.
* Bound to src/content/faq/*.md (20 items, 5 categories).
*
* Props:
* - limit?: number (default: 4 — matches v7-5 hardcoded count)
* - category?: string (filter by faq.data.category)
* - id?: string (default: 'faq')
* - showHeader?: boolean (default: true)
*/
import { getCollection } from 'astro:content';
interface Props {
limit?: number;
category?: string;
id?: string;
showHeader?: boolean;
}
const {
limit = 4,
category,
id = 'faq',
showHeader = true,
} = Astro.props;
const all = await getCollection('faq');
const filtered = category
? all.filter((f) => f.data.category === category)
: all;
const items = filtered.slice(0, limit);
// Hardcoded "em" highlights for the 4 default items (matches v7-5 demo)
// For items from collection, we apply a simple heuristic: bold common keywords
const defaultHighlights: Record<string, { q: string[]; a: string[] }> = {
'มอร์มินิมอร์ทำอะไรบ้าง?': { q: ['เอง'], a: ['AI'] },
'ราคาเริ่มต้นเท่าไหร่?': { q: ['เห็นผล'], a: ['14-30 วัน', '3 เดือน'] },
'ใช้เวลาเห็นผลนานไหม?': { q: ['เห็นผล'], a: ['14-30 วัน', '3 เดือน'] },
'AI จะแทนที่พนักงานไหม?': { q: ['พนักงาน'], a: ['ผู้ช่วย'] },
'มีบริการหลังขายไหม?': { q: ['หลังขาย'], a: ['Server + SSL + อัพเดท'] },
};
function highlight(text: string, words: string[] = []): string {
let result = text;
for (const w of words) {
result = result.replace(w, `<em>${w}</em>`);
}
return result;
}
---
<div id={id} class="fx-faq fx-reveal">
{showHeader && (
<div class="fx-section-header">
<span class="fx-section-eyebrow">// faq</span>
<h2 class="fx-section-title">คำถามที่ถามบ่อย</h2>
<p class="fx-section-lede">คำตอบสั้น ๆ ตรง ๆ — ไม่มีน้ำ</p>
</div>
)}
<div class="fx-faq-list fx-stagger">
{items.map((item, i) => {
const highlights = defaultHighlights[item.data.question] ?? { q: [], a: [] };
const qHtml = highlight(item.data.question, highlights.q);
// Wrap "user" / "bot" prefix in answer if not already present
let aHtml = highlight(item.data.answer, highlights.a);
if (!aHtml.includes('<em>user</em>')) {
aHtml = `<em>user</em> ถาม / <em>bot</em> ตอบ — ${aHtml}`;
}
return (
<div class="fx-faq-item" data-coord={`Q.${i + 1}`}>
<div class="fx-faq-q" set:html={qHtml} />
<div class="fx-faq-a" set:html={aHtml} />
</div>
);
})}
</div>
{items.length >= limit && (
<div class="fx-faq-more">
<a href="/faq" class="fx-btn ghost">ดูคำถามทั้งหมด ({all.length} ข้อ) →</a>
</div>
)}
</div>

View File

@@ -1,714 +1,97 @@
---
/**
* MOREMINIMORE - KINETIC HERO COMPONENT (LIGHT THEME)
* Yellow/white/black editorial — no dark bg
* MOREMINIMORE - HERO (from v6-hero · terminal+stats)
* Extracted from Desktop/moreminomore-mockup-v7-5.html lines 772-820
*
* 2-col grid: text (left, with $ command + eyebrow) + 2x2 stats sidebar (right)
* Uses 5 sparkle decorations (✦ ◆ ·) for "Neon × Tech Grid" feel.
*
* Props:
* - eyebrow?: string (default: 'MOREMINIMORE / EST. 2024')
* - title?: string (default: from home.md badge)
* - lede?: string (default: 1-sentence pitch)
* - ctaPrimary?: { text, href } (default: ปรึกษาฟรี → /contact)
* - ctaSecondary?: { text, href } (default: ดูผลงานจริง → /portfolio)
* - stats?: { label, value, coral?: boolean }[] (default: 4 Dataroot stats)
* - id?: string (default: 'hero' — for anchor links)
*/
import Icon from './Icon.astro';
interface Stat {
label: string;
value: string;
coral?: boolean;
}
interface CTA {
text: string;
href: string;
}
interface Props {
badge?: string;
title: string;
subtitle?: string;
showCTA?: boolean;
ctaText?: string;
ctaLink?: string;
eyebrow?: string;
title?: string;
lede?: string;
ctaPrimary?: CTA;
ctaSecondary?: CTA;
stats?: Stat[];
id?: string;
}
const {
badge = 'Moreminimore',
title = 'เราจะช่วยคุณเพิ่มกำไร',
subtitle = 'เราช่วยวางระบบงาน และใช้สถิติวางกลยุทธ์ทางการตลาด',
showCTA = true,
ctaText = 'เริ่มปรึกษาฟรี',
ctaLink = '/contact',
pains = [
{ surface: 'yellow', text: 'ยิ่งขาย กำไรยิ่งลด?' },
{ surface: 'purple-soft', text: 'มีเว็บไซต์ เหมือนไม่มี?' },
{ surface: 'mint', text: 'พนักงานทำงานได้น้อยกว่าที่ต้องการ?' },
{ surface: 'teal', text: 'เอา AI มาให้ใช้ แต่งานไม่ได้มากขึ้นตามที่คิด?' },
eyebrow = 'MOREMINIMORE / EST. 2024',
title = 'เราจะช่วยคุณเพิ่มกำไร ไม่ใช่แค่เพิ่มงบ',
lede = 'วางระบบ AI + Online Marketing + Automation ให้ธุรกิจคุณทำงานเร็วขึ้น ใช้งบคุ้ม และเห็นผลจริง',
ctaPrimary = { text: 'ปรึกษาฟรี →', href: '/contact' },
ctaSecondary = { text: 'ดูผลงานจริง', href: '/portfolio' },
stats = [
{ label: 'impression', value: '+373%' },
{ label: 'click', value: '+114%', coral: true },
{ label: 'ad_spend', value: '28%' },
{ label: 'period', value: '30d' },
],
id = 'hero',
} = Astro.props;
// Split title into words for kinetic animation
const titleWords = title.split(' ');
// Split title into 2 lines at first comma/space — for fx-hero-title multi-line layout
const titleLines = title.split(/\s+/);
const half = Math.ceil(titleLines.length / 2);
const titleLine1 = titleLines.slice(0, half).join(' ');
const titleLine2 = titleLines.slice(half).join(' ');
---
<section class="kinetic-hero">
<!-- Animated Background Pattern (LIGHT THEME) -->
<div class="hero-bg">
<div class="bg-grid"></div>
<div class="bg-gradient"></div>
</div>
<div id={id} class="fx-hero fx-reveal">
<span class="fx-sparkle" style="top:8%;left:6%">✦</span>
<span class="fx-sparkle s2" style="top:12%;right:8%">◆</span>
<span class="fx-sparkle s3" style="bottom:18%;left:10%"></span>
<span class="fx-sparkle s4" style="top:30%;right:4%">·</span>
<span class="fx-sparkle s5" style="bottom:8%;right:12%">◆</span>
<!-- Floating Geometric Elements (yellow, subtle) -->
<div class="hero-geometric">
<div class="geo-circle geo-1"></div>
<div class="geo-circle geo-2"></div>
<div class="geo-circle geo-3"></div>
<div class="geo-ring ring-1"></div>
<div class="geo-ring ring-2"></div>
<div class="geo-line line-1"></div>
<div class="geo-line line-2"></div>
</div>
<div class="container hero-container">
<!-- 2-COLUMN LAYOUT: text (left) + pain stack (right) -->
<div class="hero-grid">
<!-- LEFT: text content -->
<div class="hero-text">
<!-- Badge -->
<div class="hero-badge" data-animate="fade-in">
<span class="badge-dot"></span>
{badge}
</div>
<!-- Main Title - Kinetic Typography -->
<h1 class="hero-title kinetic-title">
{titleWords.map((word, index) => (
<span class="word-wrapper">
<span
class="word"
style={`--delay: ${0.4 + index * 0.12}s`}
>
{word}
</span>
</span>
))}
</h1>
<!-- Accent Line -->
<div class="hero-accent-line">
<div class="accent-fill"></div>
</div>
<!-- Subtitle -->
<p class="hero-subtitle" data-animate="fade-in-up">
<Fragment set:html={subtitle} />
</p>
<!-- CTA Buttons -->
{showCTA && (
<div class="hero-actions" data-animate="fade-in-up">
<a href={ctaLink} class="btn btn-primary btn-magnetic">
<span class="btn-text">{ctaText}</span>
<svg class="btn-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</a>
<slot name="hero-cta-secondary" />
</div>
)}
<slot name="hero-trust" />
<div class="fx-hero-grid">
<div class="fx-hero-content">
<span class="fx-hero-eyebrow">{eyebrow}</span>
<h1 class="fx-hero-title">
<span>{titleLine1}</span>
<span>{titleLine2}</span>
</h1>
<p class="fx-hero-lede" set:html={lede} />
<div class="fx-hero-cta">
<a href={ctaPrimary.href} class="fx-btn coral">{ctaPrimary.text}</a>
<a href={ctaSecondary.href} class="fx-btn ghost">{ctaSecondary.text}</a>
</div>
<p class="fx-hero-trustline">
<span style="color:var(--coral)">✓</span> ปรึกษาฟรี 30 นาที &nbsp;·&nbsp;
<span style="color:var(--coral)">✓</span> ไม่มีผูกมัด &nbsp;·&nbsp;
<span style="color:var(--coral)">✓</span> เห็นผล <em>ภายใน 30 วัน</em>
</p>
</div>
<!-- RIGHT: pain card stack -->
<div class="hero-pain-stack" data-animate="fade-in-up">
{pains.map((p, i) => (
<div class={`pain-card pain-${p.surface}`} style={`--pain-delay: ${0.6 + i * 0.15}s`}>
<div class="pain-eyebrow">คุณกำลังเจอปัญหา</div>
<div class="pain-text">{p.text}</div>
<div class="fx-hero-side">
{stats.map((stat, i) => (
<div class="fx-stat" data-coord={`00.${i + 1}`}>
<div class="fx-stat-label">{stat.label}</div>
<div class:list={['fx-stat-value', stat.coral && 'coral']}>
<Fragment set:html={stat.value} />
</div>
))}
</div>
</div>
))}
</div>
</div>
<!-- Giant Background Typography -->
<div class="hero-bg-text" aria-hidden="true">
AI
</div>
<!-- Scroll Indicator -->
<div class="scroll-indicator">
<span class="scroll-text">เลื่อนลง</span>
<div class="scroll-line">
<div class="scroll-dot"></div>
</div>
</div>
</section>
<style>
/* ============================================
HERO BASE — LIGHT THEME (white bg, yellow accent)
============================================ */
.kinetic-hero {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
background: var(--color-white);
overflow: hidden;
}
/* ============================================
BACKGROUND
============================================ */
.hero-bg {
position: absolute;
inset: 0;
z-index: 0;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(254, 212, 0, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(254, 212, 0, 0.08) 1px, transparent 1px);
background-size: 60px 60px;
animation: gridMove 20s linear infinite;
}
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(60px, 60px); }
}
.bg-gradient {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 30% 20%, rgba(254, 212, 0, 0.15) 0%, transparent 50%),
radial-gradient(ellipse at 70% 80%, rgba(254, 212, 0, 0.08) 0%, transparent 50%);
}
/* ============================================
GEOMETRIC ELEMENTS (yellow on light — very subtle)
============================================ */
.hero-geometric {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.geo-circle {
position: absolute;
border-radius: 50%;
background: var(--color-primary);
}
.geo-1 {
width: 400px;
height: 400px;
top: -100px;
right: 10%;
opacity: 0.08;
animation: floatGeo1 12s ease-in-out infinite;
}
.geo-2 {
width: 250px;
height: 250px;
bottom: 10%;
left: -50px;
opacity: 0.12;
animation: floatGeo2 10s ease-in-out infinite;
}
.geo-3 {
width: 150px;
height: 150px;
top: 40%;
right: 30%;
opacity: 0.06;
animation: floatGeo3 8s ease-in-out infinite;
}
@keyframes floatGeo1 {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
50% { transform: translate(-30px, -50px) rotate(45deg); }
}
@keyframes floatGeo2 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(40px, -30px); }
}
@keyframes floatGeo3 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-20px, 30px) scale(1.1); }
}
.geo-ring {
position: absolute;
border: 2px solid var(--color-primary);
border-radius: 50%;
opacity: 0.2;
}
.ring-1 {
width: 300px;
height: 300px;
top: 10%;
left: 60%;
animation: rotateRing 20s linear infinite;
}
.ring-2 {
width: 180px;
height: 180px;
bottom: 20%;
right: 20%;
animation: rotateRing 15s linear infinite reverse;
}
@keyframes rotateRing {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.geo-line {
position: absolute;
background: var(--color-primary);
opacity: 0.2;
}
.line-1 {
width: 200px;
height: 2px;
top: 25%;
right: 15%;
animation: lineSlide 8s ease-in-out infinite;
}
.line-2 {
width: 2px;
height: 150px;
bottom: 30%;
left: 25%;
animation: lineSlide 10s ease-in-out infinite reverse;
}
@keyframes lineSlide {
0%, 100% { transform: translateX(0); opacity: 0.2; }
50% { transform: translateX(30px); opacity: 0.3; }
}
/* ============================================
CONTAINER & CONTENT
============================================ */
.hero-container {
position: relative;
z-index: 1;
padding: 140px var(--gutter) 100px;
max-width: 1600px;
}
/* ============================================
HERO GRID — 2 COLUMNS (text + pain stack)
============================================ */
.hero-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
align-items: center;
}
.hero-text {
min-width: 0;
}
.hero-pain-stack {
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
}
.pain-card {
padding: 28px 32px;
border-radius: var(--radius-xl);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
border: 1px solid transparent;
opacity: 0;
transform: translateY(20px);
animation: painReveal 0.7s var(--ease-out-expo) var(--pain-delay, 0s) forwards;
transition: transform 0.4s var(--ease-out-expo);
}
.pain-card:hover {
transform: translateY(-4px);
}
@keyframes painReveal {
to {
opacity: 1;
transform: translateY(0);
}
}
.pain-yellow {
background: var(--color-primary);
color: var(--color-black);
}
.pain-purple-soft {
background: var(--color-purple-soft);
color: var(--color-black);
}
.pain-mint {
background: var(--color-mint-soft);
color: var(--color-black);
}
.pain-teal {
background: var(--color-teal);
color: var(--color-white);
}
.pain-teal .pain-eyebrow { color: var(--color-primary); }
.pain-eyebrow {
font-family: var(--font-display);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 2px;
opacity: 0.7;
margin-bottom: 8px;
}
.pain-text {
font-family: var(--font-display);
font-size: 22px;
font-weight: 800;
line-height: 1.3;
}
@media (max-width: 1024px) {
.hero-grid {
grid-template-columns: 1fr;
gap: 40px;
}
.hero-text {
order: 1;
}
.hero-pain-stack {
order: 2;
}
}
@media (max-width: 640px) {
.pain-text { font-size: 18px; }
.pain-card { padding: 20px 24px; }
}
/* ============================================
BADGE
============================================ */
.hero-badge {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
background: var(--color-primary);
border: 1px solid var(--color-primary);
border-radius: var(--radius-full);
font-family: var(--font-display);
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 3px;
color: var(--color-black);
margin-bottom: 40px;
opacity: 0;
animation: fadeIn 0.6s var(--ease-out-expo) 0.2s forwards;
}
.badge-dot {
width: 8px;
height: 8px;
background: var(--color-black);
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.5; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* ============================================
KINETIC TITLE — DARK TEXT on white bg
============================================ */
.hero-title {
font-family: var(--font-display);
font-size: clamp(40px, 8vw, 90px);
font-weight: 900;
/* CRITICAL: line-height must be >1.2 to prevent Thai vowel clipping
when combined with overflow:hidden on .word-wrapper. */
line-height: 1.3;
letter-spacing: -0.01em;
color: var(--color-black);
margin-bottom: 32px;
}
.word-wrapper {
display: inline-block;
/* Use padding (not overflow:hidden) so Thai descenders stay visible. */
padding: 0.1em 0.05em;
margin: -0.1em -0.05em;
}
.word {
display: inline-block;
opacity: 0;
transform: translateY(100%) skewY(10deg);
animation: wordReveal 0.9s var(--ease-out-expo) forwards;
animation-delay: var(--delay);
}
@keyframes wordReveal {
0% {
opacity: 0;
transform: translateY(100%) skewY(10deg);
}
60% {
opacity: 1;
transform: translateY(-5%) skewY(-2deg);
}
100% {
opacity: 1;
transform: translateY(0) skewY(0);
}
}
/* ============================================
ACCENT LINE (yellow)
============================================ */
.hero-accent-line {
width: 200px;
height: 6px;
background: var(--color-gray-200);
border-radius: 3px;
margin-bottom: 32px;
overflow: hidden;
opacity: 0;
animation: fadeIn 0.6s var(--ease-out-expo) 1s forwards;
}
.accent-fill {
width: 100%;
height: 100%;
background: var(--color-primary);
border-radius: 3px;
transform-origin: left;
animation: accentDraw 1s var(--ease-out-expo) 1.2s forwards;
transform: scaleX(0);
}
@keyframes accentDraw {
to { transform: scaleX(1); }
}
/* ============================================
SUBTITLE
============================================ */
.hero-subtitle {
font-size: 20px;
line-height: 1.7;
color: var(--color-gray-700);
max-width: 600px;
margin-bottom: 32px;
opacity: 0;
animation: fadeInUp 0.8s var(--ease-out-expo) 0.8s forwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ============================================
CTA BUTTONS
============================================ */
.hero-actions {
display: flex;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 32px;
opacity: 0;
animation: fadeInUp 0.8s var(--ease-out-expo) 1s forwards;
}
.btn-magnetic {
position: relative;
padding: 22px 44px;
font-size: 16px;
}
.btn-arrow {
width: 24px;
height: 24px;
transition: transform 0.3s var(--ease-out-expo);
}
.btn-magnetic:hover .btn-arrow {
transform: translateX(8px);
}
/* ============================================
TRUST STRIP
============================================ */
.hero-trust {
display: flex;
gap: 12px;
flex-wrap: wrap;
font-size: 14px;
color: var(--color-gray-600);
opacity: 0;
animation: fadeInUp 0.8s var(--ease-out-expo) 1.2s forwards;
}
.trust-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.trust-icon { color: var(--color-primary); flex-shrink: 0; }
/* ============================================
BACKGROUND TEXT (light yellow watermark)
============================================ */
.hero-bg-text {
position: absolute;
right: -5%;
top: 50%;
transform: translateY(-50%);
font-family: var(--font-display);
font-size: clamp(200px, 30vw, 400px);
font-weight: 900;
text-transform: uppercase;
color: var(--color-primary);
opacity: 0.08;
pointer-events: none;
user-select: none;
line-height: 1;
}
/* ============================================
SCROLL INDICATOR (dark on light)
============================================ */
.scroll-indicator {
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
opacity: 0;
animation: fadeIn 0.6s var(--ease-out-expo) 1.5s forwards;
}
.scroll-text {
font-family: var(--font-display);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 3px;
color: var(--color-gray-500);
}
.scroll-line {
width: 2px;
height: 60px;
background: var(--color-gray-200);
border-radius: 1px;
position: relative;
overflow: hidden;
}
.scroll-dot {
width: 4px;
height: 4px;
background: var(--color-primary);
border-radius: 50%;
position: absolute;
left: -1px;
animation: scrollDot 2s ease-in-out infinite;
}
@keyframes scrollDot {
0% { top: 0; opacity: 1; }
80% { top: 100%; opacity: 0; }
100% { top: 100%; opacity: 0; }
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 1024px) {
.hero-title {
font-size: clamp(32px, 7vw, 64px);
}
.hero-container {
padding: 120px var(--gutter) 80px;
}
.hero-bg-text {
display: none;
}
.scroll-indicator {
bottom: 60px;
}
}
@media (max-width: 640px) {
.hero-title {
font-size: clamp(28px, 9vw, 48px);
}
.hero-subtitle {
font-size: 16px;
}
.hero-actions {
flex-direction: column;
width: 100%;
}
.hero-actions .btn {
width: 100%;
justify-content: center;
}
.hero-badge {
font-size: 11px;
padding: 10px 16px;
}
.hero-accent-line {
width: 120px;
}
.scroll-indicator {
display: none;
}
}
</style>
</div>

View File

@@ -0,0 +1,82 @@
---
/**
* MOREMINIMORE - PORTFOLIO (from v6-portfolio · modal cards)
* Extracted from Desktop/moreminomore-mockup-v7-5.html lines 1122-1152
*
* 2-1-1 modal grid: 1 large featured + 2 smaller cards.
* Per plan 2026-06-13 round 2: PINNED to 3 items (Dataroot → Luadjob → Jet).
* Reordering the array reorders the grid; first item is "featured" (large).
*
* Props:
* - id?: string (default: 'portfolio')
* - showHeader?: boolean (default: true)
*/
import { getCollection } from 'astro:content';
interface Props {
id?: string;
showHeader?: boolean;
}
const { id = 'portfolio', showHeader = true } = Astro.props;
// Pinned 3 (per user 2026-06-13 round 2 #2)
const FEATURED_SLUGS = ['dataroot', 'luadjob', 'jet-industries'] as const;
const all = await getCollection('portfolio');
const items = FEATURED_SLUGS
.map((slug) => all.find((p) => p.id === slug))
.filter((p): p is NonNullable<typeof p> => p !== undefined);
// Tag per item (top-right of card)
const tags = ['FLAGSHIP', 'E-COMMERCE', 'B2B'] as const;
// Short stats per item
const statsLabels = [
'+373% impression · -28% ad spend',
'E-commerce สมุนไพรไทย',
'โรงงาน B2B · เว็บทันสมัย',
];
// Background images (use local thumbnails; fall back to neutral)
const fallbackBg = '#FFD60A';
---
<div id={id} class="fx-portfolio fx-reveal">
{showHeader && (
<div class="fx-section-header">
<span class="fx-section-eyebrow">// portfolio</span>
<h2 class="fx-section-title">ผลงานจริง ไม่ใช่ Mockup</h2>
<p class="fx-section-lede">ลูกค้าจริง ตัวเลขจริง — คลิกดูเว็บจริงได้เลย</p>
</div>
)}
<div class="fx-portfolio-grid fx-stagger">
{items.map((item, i) => {
const nameParts = item.data.name.split(' ');
const nameDisplay = nameParts.length > 1
? nameParts[0] + '<em>' + nameParts.slice(1).join(' ') + '</em>'
: item.data.name;
const isFeatured = i === 0;
return (
<a
href={item.data.url ?? '/portfolio'}
target={item.data.url ? '_blank' : undefined}
rel={item.data.url ? 'noopener' : undefined}
class:list={['fx-portfolio-card', isFeatured && 'featured']}
data-path={item.id}
data-coord={`04.${String.fromCharCode(65 + i)}`}
>
<span class="fx-portfolio-tag">{tags[i] ?? 'CASE'}</span>
<img
src={item.data.thumbnail ?? '/images/portfolio/default.jpg'}
alt={item.data.name}
loading="lazy"
style={item.data.thumbnail ? '' : `background:${fallbackBg};min-height:200px`}
/>
<h3 class="fx-portfolio-name" set:html={nameDisplay} />
<div class="fx-portfolio-stats">{statsLabels[i] ?? item.data.category_label}</div>
</a>
);
})}
</div>
</div>

View File

@@ -0,0 +1,68 @@
---
/**
* MOREMINIMORE - PRICING (from v6-pricing · 3-tier adapted to 2-tier)
* Extracted from Desktop/moreminimore-mockup-v7-5.html lines 1273-1324
*
* Per plan 2026-06-13 round 2 #1: 2 webdev tiers only.
* - Astro: ฿5,000 (featured, yellow border-left)
* - WordPress: ฿30,000
*
* Component uses `repeat(auto-fit, minmax(280px, 1fr))` to gracefully
* accept 2 OR 3 tiers (in case user adds a Landing tier later).
*
* Props:
* - id?: string (default: 'pricing')
* - showHeader?: boolean (default: true)
*/
import { getCollection } from 'astro:content';
interface Props {
id?: string;
showHeader?: boolean;
}
const { id = 'pricing', showHeader = true } = Astro.props;
const allTiers = await getCollection('pricing');
const tiers = allTiers.sort((a, b) => (a.data.order ?? 99) - (b.data.order ?? 99));
const coordLetters = ['A', 'B', 'C', 'D'];
---
<div id={id} class="fx-pricing fx-reveal">
{showHeader && (
<div class="fx-section-header">
<span class="fx-section-eyebrow">// pricing</span>
<h2 class="fx-section-title">ราคาเว็บไซต์</h2>
<p class="fx-section-lede">เฉพาะ Website Development — บริการอื่นปรึกษาฟรีเพื่อประเมินราคา</p>
</div>
)}
<div class="fx-pricing-grid fx-stagger">
{tiers.map((tier, i) => {
const d = tier.data;
// Highlight last word in tier (v7-5 style: "เริ่ม<em>ต้น</em>")
const tierWords = d.tier.split(/(?=[^ก-๛]*$)/);
const tierDisplay = tierWords.length > 1
? tierWords[0] + '<em>' + tierWords.slice(1).join('') + '</em>'
: d.tier;
return (
<div
class:list={['fx-pricing-card', d.is_featured && 'featured']}
data-coord={`05.${coordLetters[i] ?? 'X'}`}
>
<div class="fx-pricing-tier" set:html={d.is_featured ? `★ <em>${d.tier}</em>` : tierDisplay} />
<h3 class="fx-pricing-name">{d.name}</h3>
<div class="fx-pricing-amount">{d.amount}</div>
<div class="fx-pricing-period">/ {d.period}</div>
<ul class="fx-pricing-features">
{d.features.map((f) => <li>{f}</li>)}
</ul>
<a href="/contact" class="fx-pricing-cta">
{d.is_featured ? 'เลือก' : 'เริ่มต้น'}<em> →</em>
</a>
</div>
);
})}
</div>
</div>

View File

@@ -0,0 +1,57 @@
---
/**
* MOREMINIMORE - PROCESS (from v6-process · 4-col flow)
* Extracted from Desktop/moreminomore-mockup-v7-5.html lines 1198-1230
*
* 4 numbered cells with arrow connectors.
* Per plan 2026-06-13 round 2 #5: hardcoded (process IS MoreminiMore's flow,
* not user-editable content).
*
* Props:
* - id?: string (default: 'process')
* - showHeader?: boolean (default: true)
*/
interface Step {
num: string;
title: string;
cmd: string;
cmdArg: string;
coord: string;
}
interface Props {
id?: string;
showHeader?: boolean;
}
const { id = 'process', showHeader = true } = Astro.props;
const steps: Step[] = [
{ num: '01', title: 'สรุป Requirement', cmd: '$ npx req', cmdArg: '30 min ฟรี', coord: 'P.1' },
{ num: '02', title: 'วิเคราะห์ Flow', cmd: '$ npx analyze', cmdArg: 'ปัจจุบัน', coord: 'P.2' },
{ num: '03', title: 'ออกแบบ + เลือก Tech', cmd: '$ npx design', cmdArg: 'เครื่องมือ', coord: 'P.3' },
{ num: '04', title: 'พัฒนา + ทดสอบ', cmd: '$ npx build', cmdArg: 'ดูทุกขั้น', coord: 'P.4' },
];
---
<div id={id} class="fx-process fx-reveal">
{showHeader && (
<div class="fx-section-header">
<span class="fx-section-eyebrow">// process</span>
<h2 class="fx-section-title">ขั้นตอนการทำงาน</h2>
<p class="fx-section-lede">เริ่มจากคุย requirement ฟรี → ส่งมอบตามที่ตกลง</p>
</div>
)}
<div class="fx-process-grid fx-stagger">
{steps.map((step) => (
<div class="fx-process-step" data-coord={step.coord}>
<div class="fx-process-num">{step.num}</div>
<div class="fx-process-title">{step.title}</div>
<div class="fx-process-text">
{step.cmd} <em>{step.cmdArg}</em>
</div>
</div>
))}
</div>
</div>

View File

@@ -0,0 +1,79 @@
---
/**
* MOREMINIMORE - SERVICES (from v6-services)
* Extracted from Desktop/moreminomore-mockup-v7-5.html lines 962-1014
*
* 4 cards in 2x2 grid. Data bound to src/content/services/*-new.mdx (4 entries).
* Each card: number (01-04) + title (with <em> accent) + desc + 3 bullets.
*
* Features extracted heuristically from short_desc + objective.
* For richer features, services collection could be extended with `features: string[]`.
*
* Props:
* - id?: string (default: 'services')
* - limit?: number (default: 4)
* - showHeader?: boolean (default: true — renders section title)
*/
import { getCollection } from 'astro:content';
interface Props {
id?: string;
limit?: number;
showHeader?: boolean;
}
const { id = 'services', limit = 4, showHeader = true } = Astro.props;
// Filter to *-new.mdx files (4 main services) — exclude legacy files
const allServices = await getCollection('services');
const services = allServices
.filter((s) => s.id.endsWith('-new'))
.slice(0, limit);
// 3 hardcoded feature bullets per service — matches v7-5's 4 service cards
const featuresByTitle: Record<string, string[]> = {
'AI Consult': ['วิเคราะห์ workflow', 'เก็บความรู้พนักงาน', 'AI Chatbot ในองค์กร'],
'Online Marketing Consult': ['SEO + GEO + Ads', 'AI + n8n Automation', 'ตรงกลุ่มเป้าหมาย'],
'Automation Consult': ['n8n + LLM + Custom', 'เชื่อมระบบเดิม', 'Workflow อัตโนมัติ'],
'Website Development': ['Astro / WordPress', 'SEO + GEO', 'Server + SSL ฟรีปีแรก'],
};
const coordLetters = ['A', 'B', 'A', 'B'];
const coordNums = ['02', '02', '03', '03'];
---
<div id={id} class="fx-services fx-reveal">
{showHeader && (
<div class="fx-section-header">
<span class="fx-section-eyebrow">// services</span>
<h2 class="fx-section-title">เราทำอะไรได้บ้าง</h2>
<p class="fx-section-lede">เริ่มจากอันที่ปวดที่สุด ค่อยขยายไปอันอื่น</p>
</div>
)}
<div class="fx-services-grid fx-stagger">
{services.map((service, i) => {
const title = service.data.title;
const desc = service.data.short_desc ?? service.data.subtitle;
const features = featuresByTitle[title] ?? [];
// Highlight the first word + last word in title (v7-5 style)
const titleWords = title.split(' ');
const titleMid = titleWords.length > 1
? titleWords.map((w, idx) => idx === titleWords.length - 1 ? `<em>${w}</em>` : w).join(' ')
: title;
return (
<a
href={`/services/${service.id.replace(/-new$/, '')}`}
class="fx-service-card"
data-coord={`${coordNums[i]}.${coordLetters[i]}`}
>
<span class="fx-service-num">{String(i + 1).padStart(2, '0')}</span>
<h3 class="fx-service-title" set:html={titleMid} />
<p class="fx-service-desc">{desc}</p>
<ul class="fx-service-list">
{features.map((f) => <li>{f}</li>)}
</ul>
</a>
);
})}
</div>
</div>

View File

@@ -96,6 +96,23 @@ const blog = defineCollection({
}),
});
// =============================================================================
// PRICING — webdev tiers only (per plan 2026-06-13 round 2 #1)
// 2 entries: astro (฿5,000 featured) + wordpress (฿30,000)
// =============================================================================
const pricing = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/pricing' }),
schema: z.object({
name: z.string(),
tier: z.string(),
amount: z.string(),
period: z.string(),
is_featured: z.boolean().optional(),
order: z.number().optional(),
features: z.array(z.string()),
}),
});
export const collections = {
services,
portfolio,
@@ -103,4 +120,5 @@ export const collections = {
settings,
blog,
pages,
pricing,
};

View File

@@ -0,0 +1,18 @@
---
name: "Astro"
tier: "แนะนำ"
amount: "฿5,000"
period: "starter"
is_featured: true
order: 1
features:
- "Responsive design (มือถือ + เดสก์ท็อป)"
- "SEO + GEO (ติด Google + ChatGPT/Perplexity)"
- "AI ช่วยสร้างเนื้อหา"
- "Server + SSL ฟรีปีแรก"
- "แก้ไขเนื้อหาฟรีตลอดอายุ Server"
---
# Astro Website (แนะนำ)
เว็บไซต์ที่ขายได้ ไม่ใช่เว็บที่สวย — เริ่มต้น 5,000 บาท พร้อม SEO + AI ช่วยเขียนเนื้อหา

View File

@@ -0,0 +1,18 @@
---
name: "WordPress"
tier: "ขั้นสูง"
amount: "฿30,000"
period: "advanced"
is_featured: false
order: 2
features:
- "ไม่จำกัดจำนวนหน้า"
- "ตะกร้า + ชำระเงิน (WooCommerce)"
- "Plugin + Theme ตามต้องการ"
- "หลังบ้านใช้ง่าย ไม่ต้องเขียนโค้ด"
- "Server + SSL ฟรีปีแรก"
---
# WordPress Website
เว็บไซต์ E-commerce หรือเว็บที่ต้องการ Plugin เยอะ — เริ่มต้น 30,000 บาท