Add Instagram post fetching functionality and local JSON fallback
- Introduced a new TypeScript file `bu-sunaidah-fetch.ts` to handle fetching the latest Instagram posts for Bu Sunaidah. - Implemented a scraping method to attempt to retrieve posts directly from Instagram, with a fallback to a local JSON file `bu-sunaidah-posts.json`. - Added a new JSON file containing recent Instagram post URLs and captions. - Included a new image asset `unitree-l2.jpg` for accessories.
This commit is contained in:
@@ -107,6 +107,122 @@ export default async function AccessoryDetailPage({ params }: { params: Promise<
|
||||
</section>
|
||||
</MotionSection>
|
||||
|
||||
{/* HIGHLIGHTS */}
|
||||
{accessory.highlights && accessory.highlights.length > 0 && (
|
||||
<MotionSection>
|
||||
<ul className="acc-highlights" role="list">
|
||||
{accessory.highlights.map((h) => (
|
||||
<li key={h.label} className="acc-highlight">
|
||||
<span className="acc-highlight-value">{h.value}</span>
|
||||
<span className="acc-highlight-label">{h.label}</span>
|
||||
{h.sub && <span className="acc-highlight-sub">{h.sub}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</MotionSection>
|
||||
)}
|
||||
|
||||
{/* RICH FEATURE SECTIONS — full-bleed alternating */}
|
||||
{accessory.featureSections && accessory.featureSections.length > 0 && (
|
||||
<div className="acc-features-stack">
|
||||
{accessory.featureSections.map((s, i) => {
|
||||
const reverse = i % 2 === 1;
|
||||
const hasImage = Boolean(s.image);
|
||||
return (
|
||||
<MotionSection key={i}>
|
||||
<article className={`acc-feature${reverse ? ' is-reverse' : ''}${hasImage ? '' : ' is-text-only'}`}>
|
||||
{hasImage && (
|
||||
<div className="acc-feature-media">
|
||||
<Image
|
||||
src={s.image!}
|
||||
alt={s.imageAlt ?? s.title}
|
||||
fill
|
||||
sizes="(max-width: 820px) 100vw, 50vw"
|
||||
style={{ objectFit: 'cover', objectPosition: 'center' }}
|
||||
/>
|
||||
<span className="acc-feature-media-overlay" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
<div className="acc-feature-body">
|
||||
{s.eyebrow && <span className="eyebrow">{s.eyebrow}</span>}
|
||||
<h2>{s.title}</h2>
|
||||
<p>{s.body}</p>
|
||||
{s.bullets && s.bullets.length > 0 && (
|
||||
<ul className="acc-feature-bullets" role="list">
|
||||
{s.bullets.map((b) => (
|
||||
<li key={b}>
|
||||
<span className="acc-feature-check"><Check size={12} strokeWidth={2.4} /></span>
|
||||
{b}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</MotionSection>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PARAMS DIAGRAM */}
|
||||
{accessory.paramsImage && (
|
||||
<MotionSection>
|
||||
<figure className="acc-params-figure">
|
||||
<Image
|
||||
src={accessory.paramsImage}
|
||||
alt={`${accessory.name} dimensions and parameters diagram`}
|
||||
width={2000}
|
||||
height={859}
|
||||
sizes="(max-width: 920px) 100vw, 1080px"
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
/>
|
||||
<figcaption>{accessory.name} — body dimensions and structural overview.</figcaption>
|
||||
</figure>
|
||||
</MotionSection>
|
||||
)}
|
||||
|
||||
{/* SPEC GROUPS */}
|
||||
{accessory.specGroups && accessory.specGroups.length > 0 && (
|
||||
<MotionSection>
|
||||
<section className="acc-specs">
|
||||
<header>
|
||||
<span className="eyebrow">Technical specifications</span>
|
||||
<h2>Full parameter sheet.</h2>
|
||||
</header>
|
||||
<div className="acc-spec-stack">
|
||||
{accessory.specGroups.map((g) => (
|
||||
<div key={g.title} className="acc-spec-group">
|
||||
<div className="acc-spec-head">
|
||||
<h3>{g.title}</h3>
|
||||
{g.variants && g.variants.length > 0 && (
|
||||
<div className="acc-spec-variants" role="row">
|
||||
{g.variants.map((v) => (
|
||||
<span key={v} className="acc-spec-variant">{v}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<table className="acc-spec-table">
|
||||
<tbody>
|
||||
{g.rows.map((r) => (
|
||||
<tr key={r.label}>
|
||||
<th scope="row">{r.label}</th>
|
||||
<td>{r.value}</td>
|
||||
{g.variants && g.variants.length > 1 && (
|
||||
<td>{r.valueAlt ?? r.value}</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</MotionSection>
|
||||
)}
|
||||
|
||||
{/* FEATURES + COMPATIBILITY */}
|
||||
<MotionSection>
|
||||
<div className="acc-grid">
|
||||
@@ -199,6 +315,30 @@ export default async function AccessoryDetailPage({ params }: { params: Promise<
|
||||
</section>
|
||||
</MotionSection>
|
||||
|
||||
{/* FOOTNOTES + SAFETY */}
|
||||
{(accessory.footnotes?.length || accessory.safetyNotice) && (
|
||||
<MotionSection>
|
||||
<aside className="acc-foot">
|
||||
{accessory.footnotes && accessory.footnotes.length > 0 && (
|
||||
<div className="acc-foot-block">
|
||||
<span className="eyebrow">Notes</span>
|
||||
<ol className="acc-foot-list">
|
||||
{accessory.footnotes.map((f, i) => (
|
||||
<li key={i}><sup>{i + 1}</sup> {f}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
{accessory.safetyNotice && (
|
||||
<div className="acc-foot-block">
|
||||
<span className="eyebrow">Safety</span>
|
||||
<p>{accessory.safetyNotice}</p>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</MotionSection>
|
||||
)}
|
||||
|
||||
{/* RELATED */}
|
||||
{related.length > 0 && (
|
||||
<MotionSection>
|
||||
@@ -447,6 +587,226 @@ export default async function AccessoryDetailPage({ params }: { params: Promise<
|
||||
.acc-hero-actions .cta-btn { width: 100%; justify-content: space-between; }
|
||||
}
|
||||
|
||||
/* HIGHLIGHTS */
|
||||
.acc-highlights {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
@media (min-width: 820px) { .acc-highlights { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
||||
.acc-highlight {
|
||||
position: relative;
|
||||
display: flex; flex-direction: column; gap: 0.35rem;
|
||||
padding: 1.1rem 1.15rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(120, 140, 255, 0.22);
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 100% 0%, color-mix(in srgb, var(--acc) 16%, transparent), transparent 60%),
|
||||
linear-gradient(135deg, rgba(80, 110, 255, 0.10), rgba(255, 255, 255, 0.03));
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s, transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.acc-highlight::before {
|
||||
content: ''; position: absolute;
|
||||
top: 0; right: 16px; width: 28px; height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(180, 195, 255, 0.55));
|
||||
}
|
||||
.acc-highlight:hover {
|
||||
border-color: rgba(120, 140, 255, 0.55);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.45), 0 0 22px color-mix(in srgb, var(--acc) 18%, transparent);
|
||||
}
|
||||
.acc-highlight-value {
|
||||
font-size: clamp(1.7rem, 3.2vw, 2.4rem);
|
||||
font-weight: 500; letter-spacing: -0.03em; line-height: 1;
|
||||
background: linear-gradient(180deg, #FFFFFF, #B5BDDB);
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
.acc-highlight-label {
|
||||
font-size: 0.66rem; letter-spacing: 0.22em;
|
||||
text-transform: uppercase; font-weight: 800; color: #DEE0F0;
|
||||
}
|
||||
.acc-highlight-sub {
|
||||
font-size: 0.78rem; color: #8891C7; line-height: 1.3;
|
||||
}
|
||||
|
||||
/* FEATURE SECTIONS — full-bleed alternating */
|
||||
.acc-features-stack {
|
||||
display: flex; flex-direction: column;
|
||||
gap: clamp(2rem, 4vw, 3rem);
|
||||
}
|
||||
.acc-feature {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: clamp(1.25rem, 3vw, 2rem);
|
||||
padding: clamp(1.5rem, 3vw, 2.25rem);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(74, 102, 216, 0.20);
|
||||
background:
|
||||
radial-gradient(ellipse 70% 80% at 0% 100%, color-mix(in srgb, var(--acc) 16%, transparent), transparent 60%),
|
||||
linear-gradient(135deg, rgba(18, 17, 26, 0.95), rgba(8, 8, 12, 0.97));
|
||||
box-shadow: 0 22px 50px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
}
|
||||
@media (min-width: 820px) {
|
||||
.acc-feature { grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); }
|
||||
.acc-feature.is-reverse { grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); }
|
||||
.acc-feature.is-reverse .acc-feature-media { order: 2; }
|
||||
.acc-feature.is-reverse .acc-feature-body { order: 1; }
|
||||
.acc-feature.is-text-only { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.acc-feature.is-text-only .acc-feature-media { display: none; }
|
||||
.acc-feature-media {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 11;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0e;
|
||||
border: 1px solid rgba(222, 224, 240, 0.08);
|
||||
min-height: 200px;
|
||||
}
|
||||
.acc-feature-media-overlay {
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(10, 10, 14, 0.08) 0%, rgba(10, 10, 14, 0.02) 40%, rgba(10, 10, 14, 0.55) 100%),
|
||||
radial-gradient(ellipse 60% 70% at 100% 0%, color-mix(in srgb, var(--acc) 18%, transparent), transparent 65%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.acc-feature-body {
|
||||
display: flex; flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: clamp(0.25rem, 1vw, 0.75rem);
|
||||
min-width: 0;
|
||||
}
|
||||
.acc-feature-body h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.4rem);
|
||||
font-weight: 400; letter-spacing: -0.025em;
|
||||
color: #FFFFFF;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.acc-feature-body p {
|
||||
margin: 0; color: #DEE0F0;
|
||||
font-size: clamp(0.95rem, 1.8vw, 1.05rem);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.acc-feature-bullets {
|
||||
list-style: none; margin: 0.55rem 0 0; padding: 0;
|
||||
display: flex; flex-direction: column; gap: 0.6rem;
|
||||
}
|
||||
.acc-feature-bullets li {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0.65rem;
|
||||
color: #DEE0F0; font-size: 0.92rem; line-height: 1.55;
|
||||
}
|
||||
|
||||
/* PARAMS DIAGRAM */
|
||||
.acc-params-figure {
|
||||
margin: 0;
|
||||
padding: clamp(1.2rem, 2.4vw, 1.8rem);
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(74, 102, 216, 0.22);
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% 0%, color-mix(in srgb, var(--acc) 12%, transparent), transparent 60%),
|
||||
linear-gradient(180deg, rgba(14, 13, 22, 0.95), rgba(8, 8, 12, 0.97));
|
||||
display: flex; flex-direction: column; gap: 1rem;
|
||||
box-shadow: 0 22px 52px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.acc-params-figure figcaption {
|
||||
color: #8891C7; font-size: 0.78rem;
|
||||
letter-spacing: 0.04em; text-align: center;
|
||||
}
|
||||
|
||||
/* SPEC GROUPS */
|
||||
.acc-specs { display: flex; flex-direction: column; gap: 1.25rem; }
|
||||
.acc-specs header { display: flex; flex-direction: column; gap: 0.45rem; }
|
||||
.acc-specs h2 {
|
||||
margin: 0; font-size: clamp(1.4rem, 2.8vw, 1.85rem);
|
||||
font-weight: 400; letter-spacing: -0.02em; color: #FFFFFF;
|
||||
}
|
||||
.acc-spec-stack {
|
||||
display: flex; flex-direction: column; gap: 1rem;
|
||||
}
|
||||
.acc-spec-group {
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(222, 224, 240, 0.10);
|
||||
background: linear-gradient(180deg, rgba(20, 19, 26, 0.85), rgba(10, 10, 14, 0.94));
|
||||
overflow: hidden;
|
||||
}
|
||||
.acc-spec-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 0.75rem; padding: 0.9rem 1.1rem;
|
||||
border-bottom: 1px solid rgba(74, 102, 216, 0.18);
|
||||
background: linear-gradient(180deg, rgba(74, 102, 216, 0.08), rgba(20, 19, 26, 0.6));
|
||||
}
|
||||
.acc-spec-head h3 {
|
||||
margin: 0; font-size: 0.95rem; font-weight: 600;
|
||||
color: #FFFFFF; letter-spacing: -0.005em;
|
||||
}
|
||||
.acc-spec-variants {
|
||||
display: flex; gap: 0.4rem; flex-wrap: wrap;
|
||||
}
|
||||
.acc-spec-variant {
|
||||
font-size: 0.6rem; font-weight: 800;
|
||||
letter-spacing: 0.22em; text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--acc) 70%, white);
|
||||
padding: 0.22rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--acc) 38%, transparent);
|
||||
background: rgba(10, 10, 14, 0.6);
|
||||
}
|
||||
.acc-spec-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.acc-spec-table th,
|
||||
.acc-spec-table td {
|
||||
padding: 0.7rem 1.1rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid rgba(222, 224, 240, 0.06);
|
||||
}
|
||||
.acc-spec-table tr:last-child th,
|
||||
.acc-spec-table tr:last-child td { border-bottom: none; }
|
||||
.acc-spec-table th {
|
||||
color: #A6B2D8;
|
||||
font-weight: 600;
|
||||
width: 38%;
|
||||
}
|
||||
.acc-spec-table td {
|
||||
color: #DEE0F0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* FOOTNOTES + SAFETY */
|
||||
.acc-foot {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem;
|
||||
padding: clamp(1.2rem, 2.4vw, 1.8rem);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(222, 224, 240, 0.10);
|
||||
background: linear-gradient(180deg, rgba(18, 17, 24, 0.85), rgba(10, 10, 14, 0.94));
|
||||
}
|
||||
@media (min-width: 820px) { .acc-foot { grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 1.5rem; } }
|
||||
.acc-foot-block { display: flex; flex-direction: column; gap: 0.55rem; }
|
||||
.acc-foot-list {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
display: flex; flex-direction: column; gap: 0.45rem;
|
||||
}
|
||||
.acc-foot-list li {
|
||||
color: #C9CCDE; font-size: 0.82rem; line-height: 1.55;
|
||||
}
|
||||
.acc-foot-list sup {
|
||||
color: color-mix(in srgb, var(--acc) 70%, white);
|
||||
font-weight: 800;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.acc-foot-block p { margin: 0; color: #C9CCDE; font-size: 0.82rem; line-height: 1.55; }
|
||||
|
||||
/* RELATED */
|
||||
.acc-related { display: flex; flex-direction: column; gap: 1.25rem; }
|
||||
.acc-related header { display: flex; flex-direction: column; gap: 0.45rem; }
|
||||
|
||||
@@ -19,10 +19,13 @@ import { InstagramGlyph } from '@/components/icons/InstagramGlyph';
|
||||
import {
|
||||
BU_SUNAIDAH_HANDLE,
|
||||
BU_SUNAIDAH_URL,
|
||||
BU_SUNAIDAH_POSTS,
|
||||
BU_SUNAIDAH_REEL,
|
||||
} from '@/data/bu-sunaidah';
|
||||
import { BU_SUNAIDAH_PRESS } from '@/data/bu-sunaidah-press';
|
||||
import { getLatestBuSunaidahPosts } from '@/lib/bu-sunaidah-fetch';
|
||||
|
||||
// Re-fetch latest Instagram posts every 30 minutes on the server.
|
||||
export const revalidate = 1800;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Bu Sunaidah · Emirati Robotics Persona | YS Lootah Robotics',
|
||||
@@ -74,7 +77,8 @@ function formatDate(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export default function BuSunaidahPage() {
|
||||
export default async function BuSunaidahPage() {
|
||||
const posts = await getLatestBuSunaidahPosts(9);
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
@@ -203,9 +207,9 @@ export default function BuSunaidahPage() {
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{BU_SUNAIDAH_POSTS.length > 0 ? (
|
||||
{posts.length > 0 ? (
|
||||
<div className="bs-ig-grid">
|
||||
{BU_SUNAIDAH_POSTS.map((post) => {
|
||||
{posts.map((post) => {
|
||||
const kind = post.kind ?? 'p';
|
||||
const src = `https://www.instagram.com/${kind}/${post.shortcode}/embed/`;
|
||||
return (
|
||||
@@ -632,10 +636,22 @@ export default function BuSunaidahPage() {
|
||||
border: 1px solid rgba(222, 224, 240, 0.12);
|
||||
background: rgba(14, 13, 18, 0.6);
|
||||
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.45);
|
||||
aspect-ratio: 4 / 5;
|
||||
aspect-ratio: 9 / 16;
|
||||
min-height: 620px;
|
||||
}
|
||||
/* The Instagram embed iframe ships an in-frame header (avatar +
|
||||
username + "View profile") at the top. It is cross-origin so we
|
||||
can't hide it from inside. Workaround: shift the iframe up by
|
||||
~54px and let the rounded parent clip it. Adjust if Instagram
|
||||
changes header sizing. */
|
||||
.bs-ig-tile iframe {
|
||||
width: 100%; height: 100%; display: block; border: 0;
|
||||
position: absolute;
|
||||
top: -54px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: calc(100% + 54px);
|
||||
display: block;
|
||||
border: 0;
|
||||
background: #0a0a0e;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Navbar } from '@/components/Navbar';
|
||||
import { FooterAndContact } from '@/components/FooterAndContact';
|
||||
import { CatalogClient } from './CatalogClient';
|
||||
import { AccessoriesShowcase } from '@/components/robotics/AccessoriesShowcase';
|
||||
import { RoboticsSplineShowcase } from '@/components/sections/robotics-spline-showcase';
|
||||
import { CTAButton } from '@/components/ui/CTAButton';
|
||||
import { ROBOTS } from '@/data/robots';
|
||||
import { ACCESSORIES } from '@/data/accessories';
|
||||
@@ -32,6 +33,11 @@ export default function RobotsPage() {
|
||||
<Navbar />
|
||||
|
||||
<main className="rb-main">
|
||||
{/* SPLINE SHOWCASE — top-of-page robot + hand animation */}
|
||||
<div className="rb-spline-wrap">
|
||||
<RoboticsSplineShowcase />
|
||||
</div>
|
||||
|
||||
<div className="container-wide rb-stack">
|
||||
{/* BREADCRUMBS */}
|
||||
<nav aria-label="Breadcrumb" className="rb-crumbs">
|
||||
@@ -126,6 +132,9 @@ export default function RobotsPage() {
|
||||
padding-top: clamp(5rem, 9vw, 7rem);
|
||||
padding-bottom: clamp(3rem, 6vw, 5rem);
|
||||
}
|
||||
.rb-spline-wrap {
|
||||
margin-bottom: clamp(2rem, 4vw, 3rem);
|
||||
}
|
||||
.rb-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user