Add Instagram post fetching functionality and local JSON fallback
CI/CD / test-and-build (push) Waiting to run
CI/CD / deploy (push) Blocked by required conditions

- 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:
Najjar\NajjarV02
2026-05-21 16:50:22 +04:00
parent 2d32a1e722
commit e7fde29a21
25 changed files with 1787 additions and 176 deletions
+360
View File
@@ -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; }
+22 -6
View File
@@ -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;
}
+9
View File
@@ -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;