// shared.jsx — common components used across all 3 variations
/* ---------- Photo placeholder / real image ---------- */
const Photo = ({ label, tone = 'default', style, children, src, alt = '', objectPosition = 'center' }) => {
// When a real image src is provided, render it full-bleed inside the frame.
if (src) {
return (
{children}
);
}
const cls = 'photo-ph' + (tone !== 'default' ? ' ' + tone : '');
return (
{label && {label} }
{children}
);
};
/* ---------- Logo (image) ---------- */
const LogoMark = ({ size = 56, bg = 'transparent' }) => (
);
/* ---------- Text logo (clean type-only) ---------- */
const TextLogo = ({ color = 'var(--ink)', size = 22 }) => (
SARABASE. estate
);
/* ---------- Responsive breakpoint hook ----------
desktop > 1024px, tablet 641–1024px, mobile ≤ 640px */
const useBreakpoint = () => {
const get = () => {
if (typeof window === 'undefined') return 'desktop';
const w = window.innerWidth;
return w <= 640 ? 'mobile' : w <= 1024 ? 'tablet' : 'desktop';
};
const [bp, setBp] = React.useState(get);
React.useEffect(() => {
const on = () => setBp(get());
window.addEventListener('resize', on);
return () => window.removeEventListener('resize', on);
}, []);
return bp;
};
// pick a value by breakpoint; falls back desktop→tablet→mobile when undefined
const pick = (bp, d, t, m) =>
bp === 'mobile' ? (m !== undefined ? m : (t !== undefined ? t : d))
: bp === 'tablet' ? (t !== undefined ? t : d)
: d;
/* ---------- Navigation bar (responsive: hamburger ≤1024px) ---------- */
const NavBar = ({ variant = 'light', items = ['MISSION', 'ABOUT', 'PROFILE', 'VOICE', 'CONTACT'] }) => {
const isDark = variant === 'dark';
const bp = useBreakpoint();
const isDesktop = bp === 'desktop';
const [open, setOpen] = React.useState(false);
const fg = isDark ? 'var(--warm-white)' : 'var(--ink)';
// Lock body scroll while the overlay is open
React.useEffect(() => {
document.body.style.overflow = open ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [open]);
return (
);
};
/* ---------- SNS icons row (Instagram / Mail / Tel) ---------- */
const SnsRow = ({ color = 'var(--ink)', size = 16 }) => {
const ic = { width: size, height: size, fill: 'none', stroke: color, strokeWidth: 1.4 };
return (
{/* Instagram */}
{/* Mail */}
{/* Tel */}
);
};
/* ---------- Sketch property illustration (hand-drawn, Instagram-ish) ---------- */
const SKETCH_VARIANTS = [
// 0 — 2LDK(縦割り:左にLDK、右に洋室+水回り)
(s) => (
),
// 1 — 3LDK(中央廊下+3部屋)
(s) => (
),
// 2 — 1LDK(広いLDK+小部屋)
(s) => (
),
// 3 — 4LDK(田の字)
(s) => (
),
// 4 — LDK広々(リビング中心)
(s) => (
),
// 5 — 2DK(コンパクト)
(s) => (
),
];
const SketchCard = ({ index = 0, label, height = 200, width = 280 }) => {
const draw = SKETCH_VARIANTS[index % SKETCH_VARIANTS.length];
return (
{draw('var(--pink-500)')}
{label}
SARABASE
);
};
const MiniMap = ({ height = 280 }) => (
);
/* ---------- Contact form (compact) ---------- */
const ContactForm = ({ accent = 'var(--ink)' }) => {
const inputStyle = {
width: '100%',
padding: '14px 0',
border: 'none',
borderBottom: '1px solid var(--line-strong)',
background: 'transparent',
fontFamily: 'var(--sans-jp)',
fontSize: 14,
outline: 'none',
};
const labelStyle = {
fontFamily: 'var(--mono)',
fontSize: 10,
letterSpacing: '0.18em',
color: 'var(--ink-mid)',
textTransform: 'uppercase',
display: 'block',
marginBottom: 6,
};
return (
);
};
/* ---------- Footer (shared) ---------- */
const Footer = ({ variant = 'light' }) => {
const isDark = variant === 'dark';
const bp = useBreakpoint();
const bg = isDark ? 'var(--ink)' : 'var(--cream)';
const fg = isDark ? 'var(--warm-white)' : 'var(--ink)';
const fgLow = isDark ? 'rgba(255,255,255,0.5)' : 'var(--ink-mid)';
const cols = pick(bp, '2fr 1fr 1.4fr', '1fr 1fr', '1fr');
return (
);
};
/* ---------- Section header with eyebrow + JP/EN title ---------- */
const SectionHead = ({ eyebrow, titleJp, titleEn, align = 'left', mb = 56, titleSize = 56 }) => (
{eyebrow &&
{eyebrow}
}
{titleEn && (
{titleEn}
)}
{titleJp && (
{titleJp}
)}
);
/* ---------- Testimonial data ---------- */
const TESTIMONIALS = [
{
name: 'T.K 様',
age: '50代女性',
type: 'マンション売却',
quote: '亡き母の家を手放す決断には時間がかかりました。川崎さんは何度も話を聞いてくださり、私のペースで進めてくださったので、安心して任せられました。',
},
{
name: 'M.S 様',
age: '40代ご夫婦',
type: '戸建て住み替え',
quote: '他社では数字の話ばかりでしたが、SARABASEさんは「どう暮らしたいか」から一緒に考えてくれて、心から納得して住み替えできました。',
},
{
name: 'A.Y 様',
age: '60代女性',
type: '実家の売却',
quote: '不動産屋さんって少し怖いイメージがありましたが、川崎さんは本当に親しみやすくて。最初の相談から契約まで、ずっと心強かったです。',
},
];
/* ---------- Stats data ---------- */
const STATS = [
{ num: '120+', unit: '件', label: '売買サポート実績' },
{ num: '98', unit: '%', label: 'お客様満足度' },
{ num: '15', unit: '年', label: '不動産業界経験' },
{ num: '4.9', unit: '/5', label: 'お客様評価' },
];
/* ---------- Strengths data ---------- */
const STRENGTHS = [
{ num: '01', title: '話しやすさ', body: '専門用語をかみ砕いて、対等な目線で。気兼ねなく相談していただける関係を大切にしています。' },
{ num: '02', title: '丁寧な説明', body: 'なぜその提案なのか、メリットもデメリットも、納得いただけるまでご説明します。' },
{ num: '03', title: '押しつけない姿勢', body: '決めるのはお客様。私たちは選択肢を整理し、判断のサポートをするだけです。' },
{ num: '04', title: '暮らし目線', body: '価格だけでなく、その先の暮らしを一緒に思い描きながらアドバイスします。' },
];
/* ---------- Scroll Reveal (IntersectionObserver) ---------- */
const Reveal = ({ children, delay = 0, variant = '', as: As = 'div', style, ...rest }) => {
const ref = React.useRef(null);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
// If already in view at mount (top of page), trigger immediately
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
el.classList.add('is-in');
io.unobserve(el);
}
});
}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
io.observe(el);
return () => io.disconnect();
}, []);
const cls = 'reveal' + (variant ? ' reveal-' + variant : '');
return (
{children}
);
};
/* ---------- Side Rail (fixed SNS bar) ---------- */
const SideRail = () => (
);
/* ---------- Hero crossfade slideshow (3 images, gentle Ken Burns) ---------- */
const HeroSlideshow = ({ images = [], interval = 5000 }) => {
const [state, setState] = React.useState({ idx: 0, prev: -1 });
const go = (next) => setState(s => ({ idx: next, prev: s.idx }));
React.useEffect(() => {
if (images.length <= 1) return;
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
const t = setInterval(() => {
setState(s => ({ idx: (s.idx + 1) % images.length, prev: s.idx }));
}, interval);
return () => clearInterval(t);
}, [images.length, interval]);
const { idx, prev } = state;
return (
{images.map((im, i) => (
))}
{images.map((_, i) => (
go(i)}
style={{
width: i === idx ? 26 : 9, height: 9, padding: 0, border: 'none', cursor: 'pointer',
borderRadius: 9, background: i === idx ? '#fff' : 'rgba(255,255,255,0.5)',
transition: 'width 0.5s cubic-bezier(.2,.7,.2,1), background 0.4s ease',
}}
>
))}
);
};
/* ---------- Marquee Strip (flowing horizontal images) ---------- */
const MarqueeStrip = ({ direction = 'left', items = 6, tone = 'warm', height = 200, width = 280, speed = 'normal', labels }) => {
const bp = useBreakpoint();
// shrink cards on smaller screens
const w = pick(bp, width, Math.round(width * 0.82), Math.round(width * 0.62));
const h = pick(bp, height, Math.round(height * 0.82), Math.round(height * 0.62));
// Duplicate items so the loop is seamless (track translates -50%)
const arr = [...Array(items)].map((_, i) => i);
const cls = 'marquee' + (direction === 'right' ? ' marquee--right' : '') + (speed === 'slow' ? ' marquee--slow' : '');
const defaultLabels = [
'2LDK ・ 角部屋', '3LDK ・ 南向き', '1LDK ・ コンパクト', 'LDK ・ 広々',
'4LDK ・ 戸建て', '2DK ・ リノベ', '3LDK ・ 中庭', 'LDK ・ 吹抜け',
];
const ls = labels || defaultLabels;
return (
{[...arr, ...arr].map((_, i) => (
))}
);
};
// Export to global scope so other Babel scripts can use them
Object.assign(window, {
Photo, LogoMark, TextLogo, NavBar, SnsRow, MiniMap,
ContactForm, Footer, SectionHead,
Reveal, SideRail, MarqueeStrip, SketchCard, HeroSlideshow,
useBreakpoint, pick,
TESTIMONIALS, STATS, STRENGTHS,
});