first commit
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,68 @@
|
||||
# web-anindhaloka
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js** `^20.19.0` atau `>=22.12.0`
|
||||
- **npm** (terpasang bersama Node.js)
|
||||
|
||||
## Instalasi
|
||||
|
||||
1. Clone/masuk ke folder project:
|
||||
```bash
|
||||
cd web-anindhaloka
|
||||
```
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. (Opsional) Buat file `.env` di root project untuk mengatur URL backend API:
|
||||
```
|
||||
VITE_API_URL=http://localhost:8000
|
||||
```
|
||||
Jika tidak diset, default-nya `http://localhost:8000`.
|
||||
|
||||
## Menjalankan di Local
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Buka `http://localhost:5173` di browser.
|
||||
|
||||
## Perintah Lain
|
||||
|
||||
| Perintah | Fungsi |
|
||||
|---|---|
|
||||
| `npm run dev` | Jalankan dev server |
|
||||
| `npm run build` | Type-check + build produksi ke folder `dist/` |
|
||||
| `npm run preview` | Preview hasil build produksi secara lokal |
|
||||
| `npm run lint` | Jalankan ESLint |
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Dependencies (runtime)
|
||||
|
||||
| Package | Versi |
|
||||
|---|---|
|
||||
| `framer-motion` | ^12.42.2 |
|
||||
| `lucide-react` | ^1.17.0 |
|
||||
| `react` | ^19.2.6 |
|
||||
| `react-dom` | ^19.2.6 |
|
||||
| `react-router-dom` | ^7.16.0 |
|
||||
|
||||
### devDependencies (build & tooling)
|
||||
|
||||
| Package | Versi |
|
||||
|---|---|
|
||||
| `@eslint/js` | ^10.0.1 |
|
||||
| `@types/node` | ^24.12.3 |
|
||||
| `@types/react` | ^19.2.14 |
|
||||
| `@types/react-dom` | ^19.2.3 |
|
||||
| `@vitejs/plugin-react` | ^6.0.1 |
|
||||
| `eslint` | ^10.3.0 |
|
||||
| `eslint-plugin-react-hooks` | ^7.1.1 |
|
||||
| `eslint-plugin-react-refresh` | ^0.5.2 |
|
||||
| `globals` | ^17.6.0 |
|
||||
| `typescript` | ~6.0.2 |
|
||||
| `typescript-eslint` | ^8.59.2 |
|
||||
| `vite` | ^8.0.12 |
|
||||
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/assets/logo/anindhaloka-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Anindhaloka — Rumah Gotong Royong</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "web-anindhaloka",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.42.2",
|
||||
"lucide-react": "^1.17.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 577 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 97 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { BrowserRouter as Router, Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { MotionConfig } from 'framer-motion';
|
||||
import Navbar from './components/Navbar';
|
||||
import Footer from './components/Footer';
|
||||
import PageTransition from './components/PageTransition';
|
||||
import Home from './pages/Home';
|
||||
import BhumiAnindha from './pages/BhumiAnindha';
|
||||
import PAON from './pages/PAON';
|
||||
import KaIND from './pages/KaIND';
|
||||
import CittaRahayu from './pages/CittaRahayu';
|
||||
import PasarBahagia from './pages/PasarBahagia';
|
||||
import LearnNIN from './pages/LearnNIN';
|
||||
import LokaNIN from './pages/LokaNIN';
|
||||
import PesanRuang from './pages/PesanRuang';
|
||||
import TemanNIN from './pages/TemanNIN';
|
||||
import Bergabung from './pages/Bergabung';
|
||||
import Tentang from './pages/Tentang';
|
||||
import Kontak from './pages/Kontak';
|
||||
import AdminLogin from './pages/admin/AdminLogin';
|
||||
import AdminDashboard from './pages/admin/AdminDashboard';
|
||||
import AdminContent from './pages/admin/AdminContent';
|
||||
import AdminMedia from './pages/admin/AdminMedia';
|
||||
import AdminSettings from './pages/admin/AdminSettings';
|
||||
import './index.css';
|
||||
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => { window.scrollTo(0, 0); }, [pathname]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const { pathname } = useLocation();
|
||||
const isAdmin = pathname.startsWith('/admin');
|
||||
|
||||
if (isAdmin) {
|
||||
return (
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route path="/admin" element={<AdminLogin />} />
|
||||
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
||||
<Route path="/admin/content/:page" element={<AdminContent />} />
|
||||
<Route path="/admin/media" element={<AdminMedia />} />
|
||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<ScrollToTop />
|
||||
<PageTransition />
|
||||
<Navbar />
|
||||
<main style={{ flex: 1 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/bhumi-anindha" element={<BhumiAnindha />} />
|
||||
<Route path="/paon" element={<PAON />} />
|
||||
<Route path="/kaind" element={<KaIND />} />
|
||||
<Route path="/citta-rahayu" element={<CittaRahayu />} />
|
||||
<Route path="/pasar-bahagia" element={<PasarBahagia />} />
|
||||
<Route path="/learnnin" element={<LearnNIN />} />
|
||||
<Route path="/lokanin" element={<LokaNIN />} />
|
||||
<Route path="/lokanin/pesan" element={<PesanRuang />} />
|
||||
<Route path="/temannin" element={<TemanNIN />} />
|
||||
<Route path="/bergabung" element={<Bergabung />} />
|
||||
<Route path="/tentang" element={<Tentang />} />
|
||||
<Route path="/kontak" element={<Kontak />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<Router>
|
||||
<AppContent />
|
||||
</Router>
|
||||
</MotionConfig>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,50 @@
|
||||
interface ArcDividerProps {
|
||||
/** Warna section berikutnya — bagian di bawah lengkung diisi warna ini. */
|
||||
fill: string;
|
||||
flip?: boolean;
|
||||
height?: number;
|
||||
/** Garis aksen tipis menyusuri lengkungan — dipakai supaya lengkung tetap terlihat jelas
|
||||
* meski warna section sebelum & sesudahnya sama-sama gelap/mirip. */
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
/** Divider lengkung lembut antar-section, menggantikan sambungan garis lurus.
|
||||
* Overlap negatif ke section sebelumnya bawaan, supaya bagian transparan di atas lengkung
|
||||
* menampilkan warna/gradient section sebelumnya, dan bagian terisi menyatu ke section berikutnya. */
|
||||
export default function ArcDivider({ fill, flip = false, height = 64, stroke, strokeWidth = 2 }: ArcDividerProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
lineHeight: 0,
|
||||
position: 'relative',
|
||||
marginTop: -height,
|
||||
transform: flip ? 'scaleY(-1)' : undefined,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 1440 100"
|
||||
preserveAspectRatio="none"
|
||||
width="100%"
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<path
|
||||
d="M0,40 C240,90 480,0 720,30 C960,60 1200,10 1440,45 L1440,100 L0,100 Z"
|
||||
fill={fill}
|
||||
/>
|
||||
{stroke && (
|
||||
<path
|
||||
d="M0,40 C240,90 480,0 720,30 C960,60 1200,10 1440,45"
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { animate, useInView, useReducedMotion } from 'framer-motion';
|
||||
|
||||
interface CountUpProps {
|
||||
to: number;
|
||||
duration?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Animasi angka dari 0 saat masuk viewport (dipakai untuk angka pencapaian). */
|
||||
export default function CountUp({ to, duration = 1.6, prefix = '', suffix = '', className }: CountUpProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const inView = useInView(ref, { once: true, margin: '-80px' });
|
||||
const prefersReduced = useReducedMotion();
|
||||
const [display, setDisplay] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView) return;
|
||||
const controls = animate(0, to, {
|
||||
duration: prefersReduced ? 0 : duration,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
onUpdate: (v) => setDisplay(Math.round(v)),
|
||||
});
|
||||
return () => controls.stop();
|
||||
}, [inView, to, duration, prefersReduced]);
|
||||
|
||||
return (
|
||||
<span ref={ref} className={className}>
|
||||
{prefix}{display.toLocaleString('id-ID')}{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import modules from '../data/modules';
|
||||
|
||||
interface GlyphProps {
|
||||
color: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* Tiap glyph berbagi bahasa visual yang sama dengan logo asli: lengkung tebal berujung
|
||||
* bulat (bukan garis lurus/ikon linear) + aksen "matahari" mustard yang identik di semua ikon
|
||||
* (dirender sekali di EcosystemIcon). Glyph di bawah ini hanya menggambar lengkung + satu
|
||||
* aksen pembeda kecil per modul.
|
||||
*/
|
||||
|
||||
function BhumiAnindhaGlyph({ color }: GlyphProps) {
|
||||
return (
|
||||
<>
|
||||
<path d="M9 30 Q22 16 35 30" stroke={color} strokeWidth="5.5" fill="none" strokeLinecap="round" />
|
||||
<path d="M18 21 C14 20 13 16 14 13 C17 14 18 17.5 18 21 Z" fill={color} />
|
||||
<path d="M18 21 C22 20 23 16 22 13 C19 14 18 17.5 18 21 Z" fill={color} opacity=".72" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PaonGlyph({ color }: GlyphProps) {
|
||||
return (
|
||||
<>
|
||||
<path d="M9 20 Q22 33 35 20" stroke={color} strokeWidth="5.5" fill="none" strokeLinecap="round" />
|
||||
<path d="M15 17 Q13 13 15 9" stroke={color} strokeWidth="2.6" fill="none" strokeLinecap="round" opacity=".8" />
|
||||
<path d="M23 17 Q25 13 23 9" stroke={color} strokeWidth="2.6" fill="none" strokeLinecap="round" opacity=".8" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function KaindGlyph({ color }: GlyphProps) {
|
||||
return (
|
||||
<>
|
||||
<path d="M11 23 Q22 14 33 23" stroke={color} strokeWidth="2" fill="none" strokeLinecap="round" opacity=".4" />
|
||||
<path d="M9 30 Q22 16 35 30" stroke={color} strokeWidth="5.5" fill="none" strokeLinecap="round" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CittaRahayuGlyph({ color }: GlyphProps) {
|
||||
return (
|
||||
<>
|
||||
<path d="M9 30 Q22 16 35 30" stroke={color} strokeWidth="5.5" fill="none" strokeLinecap="round" />
|
||||
<circle cx="22" cy="18" r="3.4" fill={color} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PasarBahagiaGlyph({ color }: GlyphProps) {
|
||||
return (
|
||||
<>
|
||||
<path d="M14 24 Q22 12 30 24" stroke={color} strokeWidth="4.4" fill="none" strokeLinecap="round" />
|
||||
<rect x="11" y="24" width="22" height="11" rx="4" fill={color} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LearnninGlyph({ color }: GlyphProps) {
|
||||
return (
|
||||
<>
|
||||
<path d="M9 30 Q22 16 35 30" stroke={color} strokeWidth="5.5" fill="none" strokeLinecap="round" />
|
||||
<path d="M20 10 L21.4 13.6 L26 15 L21.4 16.4 L20 21 L18.6 16.4 L14 15 L18.6 13.6 Z" fill={color} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LokaninGlyph({ color }: GlyphProps) {
|
||||
return (
|
||||
<>
|
||||
<path d="M9 30 Q22 16 35 30" stroke={color} strokeWidth="5.5" fill="none" strokeLinecap="round" />
|
||||
<rect x="17" y="24" width="10" height="12" rx="4" fill={color} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TemanninGlyph({ color }: GlyphProps) {
|
||||
return (
|
||||
<>
|
||||
<path d="M9 30 Q22 16 35 30" stroke={color} strokeWidth="2.4" fill="none" strokeLinecap="round" opacity=".55" />
|
||||
<circle cx="9" cy="30" r="3.4" fill={color} />
|
||||
<circle cx="35" cy="30" r="3.4" fill={color} />
|
||||
<circle cx="22" cy="16" r="3.4" fill={color} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const GLYPHS: Record<string, (p: GlyphProps) => ReactElement> = {
|
||||
'bhumi-anindha': BhumiAnindhaGlyph,
|
||||
paon: PaonGlyph,
|
||||
kaind: KaindGlyph,
|
||||
'citta-rahayu': CittaRahayuGlyph,
|
||||
'pasar-bahagia': PasarBahagiaGlyph,
|
||||
learnnin: LearnninGlyph,
|
||||
lokanin: LokaninGlyph,
|
||||
temannin: TemanninGlyph,
|
||||
};
|
||||
|
||||
interface EcosystemIconProps {
|
||||
id: string;
|
||||
size?: number;
|
||||
withBackdrop?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Ikon custom gaya bulat per modul ekosistem — satu keluarga visual dengan logo (lengkung + aksen matahari). */
|
||||
export default function EcosystemIcon({ id, size = 40, withBackdrop = true, className }: EcosystemIconProps) {
|
||||
const m = modules.find(mod => mod.id === id);
|
||||
const Glyph = GLYPHS[id];
|
||||
if (!m || !Glyph) return null;
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
{withBackdrop && <circle cx="22" cy="22" r="21" fill={m.colorBg} />}
|
||||
<circle cx="32" cy="9" r="5" fill="#F0B429" />
|
||||
<Glyph color={m.color} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModuleMarkProps {
|
||||
id: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
/** Bungkus dengan latar putih bulat — perlu untuk logo resmi (warnanya beragam, ada yang hitam) di atas permukaan gelap. */
|
||||
withBadge?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Penanda modul untuk slot kecil (dropdown nav, footer, dsb): pakai logo resmi bila sudah
|
||||
* tersedia (dibatasi kotak size×size, object-fit contain supaya rasio logo tetap aman),
|
||||
* jatuh ke EcosystemIcon custom bila belum ada logo untuk modul tersebut.
|
||||
*/
|
||||
export function ModuleMark({ id, size = 24, className, withBadge = false }: ModuleMarkProps) {
|
||||
const m = modules.find(mod => mod.id === id);
|
||||
if (!m) return null;
|
||||
|
||||
if (m.logoImage) {
|
||||
const badgeSize = withBadge ? size + 10 : size;
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{
|
||||
width: badgeSize, height: badgeSize, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, borderRadius: withBadge ? '50%' : undefined, background: withBadge ? '#fff' : undefined,
|
||||
boxShadow: withBadge ? '0 1px 4px rgba(0,0,0,.18)' : undefined,
|
||||
}}
|
||||
>
|
||||
<img src={m.logoImage} alt={m.name} style={{ width: size * 0.72, height: size * 0.72, objectFit: 'contain', display: 'block' }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <EcosystemIcon id={id} size={size} className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import modules from '../data/modules';
|
||||
import { ModuleMark } from './EcosystemIcons';
|
||||
|
||||
const half1 = modules.slice(0, 4);
|
||||
const half2 = modules.slice(4);
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="container">
|
||||
<div className="footer__grid">
|
||||
<div>
|
||||
<Link to="/" style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<img
|
||||
src="/assets/logo/anindhaloka-icon.png"
|
||||
alt="Anindhaloka"
|
||||
style={{ height: 28, width: 'auto', display: 'block' }}
|
||||
/>
|
||||
<span className="footer__brand-logo" style={{ margin: 0 }}>anindhaloka</span>
|
||||
</Link>
|
||||
<p className="footer__brand-desc">
|
||||
Ekosistem digital gaya hidup berkesadaran yang menghubungkan pangan, sandang, olah diri, dan komunitas dalam satu perjalanan yang bermakna.
|
||||
</p>
|
||||
<div className="footer__socials" style={{ marginTop: 24 }}>
|
||||
<a href="#" className="footer__social" aria-label="Instagram">📷</a>
|
||||
<a href="#" className="footer__social" aria-label="Facebook">📘</a>
|
||||
<a href="#" className="footer__social" aria-label="YouTube">▶️</a>
|
||||
<a href="#" className="footer__social" aria-label="WhatsApp">💬</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="footer__heading">Ekosistem I</p>
|
||||
<ul className="footer__links">
|
||||
{half1.map(m => (
|
||||
<li key={m.id}>
|
||||
<Link to={m.path} className="footer__link" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ModuleMark id={m.id} size={18} withBadge />{m.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="footer__heading">Ekosistem II</p>
|
||||
<ul className="footer__links">
|
||||
{half2.map(m => (
|
||||
<li key={m.id}>
|
||||
<Link to={m.path} className="footer__link" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ModuleMark id={m.id} size={18} withBadge />{m.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="footer__heading">Informasi</p>
|
||||
<ul className="footer__links">
|
||||
<li><Link to="/tentang" className="footer__link">Tentang Kami</Link></li>
|
||||
<li><Link to="/bergabung" className="footer__link">Bergabung & Berkolaborasi</Link></li>
|
||||
<li><Link to="/kontak" className="footer__link">Kontak</Link></li>
|
||||
<li><a href="#" className="footer__link">Kebijakan Privasi</a></li>
|
||||
<li><a href="#" className="footer__link">Syarat & Ketentuan</a></li>
|
||||
<li>
|
||||
<Link
|
||||
to="/admin"
|
||||
className="footer__link"
|
||||
style={{ opacity: 0.3, fontSize: '0.75rem' }}
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<p className="footer__heading">Kontak</p>
|
||||
<p style={{ fontSize: '.875rem', color: 'rgba(255,255,255,.5)', lineHeight: 1.7 }}>
|
||||
+62 815 7330 5808 (Welin Cakra)<br />
|
||||
@anindhaloka_id
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footer__bottom">
|
||||
<span>© 2025 Anindhaloka. Dibuat dengan cinta untuk bumi dan komunitas.</span>
|
||||
<span>🌿 Conscious Living Ecosystem</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, useReducedMotion, type Variants } from 'framer-motion';
|
||||
|
||||
const C = 300;
|
||||
const MAIN_R = 148;
|
||||
const SKILL_R = 252;
|
||||
const CAT_CR = 64;
|
||||
const SKL_CR = 33;
|
||||
|
||||
interface Cat {
|
||||
name: string[];
|
||||
sub: string[];
|
||||
fill: string;
|
||||
stroke: string;
|
||||
angle: number;
|
||||
skills: string[][];
|
||||
}
|
||||
|
||||
const cats: Cat[] = [
|
||||
{
|
||||
name: ['Being'], sub: ['Relationships', 'to Self'],
|
||||
fill: '#EDE4F8', stroke: '#7C5BAE', angle: 270,
|
||||
skills: [
|
||||
['Integrity &', 'authenticity'], ['Inner', 'compass'],
|
||||
['Openness &', 'learning'], ['Self-', 'awareness'], ['Presence'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: ['Thinking'], sub: ['Cognitive', 'Skills'],
|
||||
fill: '#FDDEEE', stroke: '#C8426A', angle: 342,
|
||||
skills: [
|
||||
['Critical', 'thinking'], ['Complexity'],
|
||||
['Perspective', 'skills'], ['Sense-', 'making'], ['Long-term', 'vision'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: ['Relating'], sub: ['Caring for', 'Others & World'],
|
||||
fill: '#D6F2EC', stroke: '#2A8E78', angle: 54,
|
||||
skills: [
|
||||
['Appreciation'], ['Connected-', 'ness'],
|
||||
['Empathy &', 'Compassion'], ['Humility'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: ['Collab-', 'orating'], sub: ['Social', 'Skills'],
|
||||
fill: '#DFF2D0', stroke: '#4A8A28', angle: 126,
|
||||
skills: [
|
||||
['Communi-', 'cation'], ['Co-creation', 'skills'],
|
||||
['Inclusive', 'mindset'], ['Trust'], ['Mobilisation'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: ['Acting'], sub: ['Enabling', 'Change'],
|
||||
fill: '#D8E8F8', stroke: '#2A6EBE', angle: 198,
|
||||
skills: [
|
||||
['Courage'], ['Creativity'],
|
||||
['Optimism'], ['Perseve-', 'rance'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const xy = (angle: number, r: number) => ({
|
||||
x: C + r * Math.cos(toRad(angle)),
|
||||
y: C + r * Math.sin(toRad(angle)),
|
||||
});
|
||||
|
||||
function skillAngles(catAngle: number, count: number) {
|
||||
const spread = count <= 1 ? 0 : Math.min(38, (count - 1) * 19);
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
count > 1 ? catAngle - spread + i * ((spread * 2) / (count - 1)) : catAngle
|
||||
);
|
||||
}
|
||||
|
||||
interface IDGWheelProps {
|
||||
/** Aktifkan bila komponen ditaruh di atas latar gelap — label eyebrow/hint diganti warna terang. */
|
||||
light?: boolean;
|
||||
}
|
||||
|
||||
const wheelContainer: Variants = {
|
||||
hidden: {},
|
||||
show: { transition: { staggerChildren: 0.09, delayChildren: 0.15 } },
|
||||
};
|
||||
const catVariants: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.3 },
|
||||
show: { opacity: 1, scale: 1, transition: { duration: 0.55, ease: [0.34, 1.56, 0.64, 1] } },
|
||||
};
|
||||
|
||||
export default function IDGWheel({ light = false }: IDGWheelProps) {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const prefersReduced = useReducedMotion();
|
||||
|
||||
function handleClick(i: number) {
|
||||
setOpen(prev => (prev === i ? null : i));
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: 460 }}>
|
||||
<p style={{
|
||||
textAlign: 'center', marginBottom: 8,
|
||||
fontFamily: 'var(--font-mono)', fontSize: '.68rem', fontWeight: 600, letterSpacing: '.15em',
|
||||
textTransform: 'uppercase', color: light ? 'var(--amber)' : 'var(--maroon-600)',
|
||||
}}>
|
||||
Inner Development Goals
|
||||
</p>
|
||||
<p style={{
|
||||
textAlign: 'center', marginBottom: 12,
|
||||
fontSize: '.72rem', color: light ? 'rgba(255,255,255,.6)' : 'var(--text-300)',
|
||||
}}>
|
||||
Klik kategori untuk melihat sub-keterampilan
|
||||
</p>
|
||||
|
||||
<motion.svg
|
||||
viewBox="0 0 600 600"
|
||||
width="100%"
|
||||
style={{ overflow: 'visible', display: 'block' }}
|
||||
aria-label="Inner Development Goals wheel"
|
||||
initial={prefersReduced ? undefined : 'hidden'}
|
||||
whileInView={prefersReduced ? undefined : 'show'}
|
||||
viewport={{ once: true, margin: '-60px' }}
|
||||
variants={prefersReduced ? undefined : wheelContainer}
|
||||
>
|
||||
{/* Outer guide ring */}
|
||||
<circle cx={C} cy={C} r={292} fill="none" stroke="var(--earth-100)"
|
||||
strokeWidth={1} strokeDasharray="4 6" />
|
||||
|
||||
{/* Inner background */}
|
||||
<circle cx={C} cy={C} r={MAIN_R + CAT_CR + 20} fill="rgba(245,240,232,.45)" />
|
||||
|
||||
{cats.map((cat, i) => {
|
||||
const cp = xy(cat.angle, MAIN_R);
|
||||
const isOpen = open === i;
|
||||
const angles = skillAngles(cat.angle, cat.skills.length);
|
||||
|
||||
/* vertical text positioning helpers */
|
||||
const nameLH = 15, subLH = 11;
|
||||
const nameTH = (cat.name.length - 1) * nameLH;
|
||||
const subTH = (cat.sub.length - 1) * subLH;
|
||||
const blockH = nameTH + 6 + subTH;
|
||||
const nameY0 = cp.y - blockH / 2;
|
||||
const subY0 = nameY0 + nameTH + 6;
|
||||
|
||||
return (
|
||||
<motion.g key={i} variants={prefersReduced ? undefined : catVariants} style={{ transformOrigin: `${cp.x}px ${cp.y}px` }}>
|
||||
{/* ── Skills group – animated out from category center ── */}
|
||||
<motion.g
|
||||
initial={false}
|
||||
animate={{ scale: isOpen ? 1 : 0, opacity: isOpen ? 1 : 0 }}
|
||||
transition={isOpen
|
||||
? { type: 'spring', stiffness: 260, damping: 18 }
|
||||
: { duration: 0.2, ease: 'easeIn' }}
|
||||
style={{
|
||||
transformOrigin: `${cp.x}px ${cp.y}px`,
|
||||
pointerEvents: isOpen ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{angles.map((a, j) => {
|
||||
const sp = xy(a, SKILL_R);
|
||||
return (
|
||||
<line key={j}
|
||||
x1={cp.x} y1={cp.y} x2={sp.x} y2={sp.y}
|
||||
stroke={cat.stroke} strokeWidth={1}
|
||||
strokeOpacity={0.22} strokeDasharray="4 4"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{cat.skills.map((lines, j) => {
|
||||
const sp = xy(angles[j], SKILL_R);
|
||||
const lh = 10.5;
|
||||
const th = (lines.length - 1) * lh;
|
||||
return (
|
||||
<g key={j}>
|
||||
<circle cx={sp.x} cy={sp.y} r={SKL_CR}
|
||||
fill="#fff" stroke={cat.stroke} strokeWidth={1.5} />
|
||||
{lines.map((ln, li) => (
|
||||
<text key={li}
|
||||
x={sp.x} y={sp.y - th / 2 + li * lh}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
fontSize={9} fontWeight="600"
|
||||
fontFamily="Inter, sans-serif" fill={cat.stroke}
|
||||
>
|
||||
{ln}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</motion.g>
|
||||
|
||||
{/* ── Category circle – always visible, clickable ── */}
|
||||
<motion.g
|
||||
onClick={() => handleClick(i)}
|
||||
style={{ cursor: 'pointer', transformOrigin: `${cp.x}px ${cp.y}px` }}
|
||||
role="button"
|
||||
aria-expanded={isOpen}
|
||||
whileHover={prefersReduced ? undefined : { scale: 1.06 }}
|
||||
whileTap={prefersReduced ? undefined : { scale: 0.97 }}
|
||||
transition={{ type: 'spring', stiffness: 350, damping: 20 }}
|
||||
>
|
||||
{/* glow ring */}
|
||||
<circle cx={cp.x} cy={cp.y} r={CAT_CR + 12}
|
||||
fill={cat.fill}
|
||||
opacity={isOpen ? 0.55 : 0.35}
|
||||
style={{ transition: 'opacity .3s' }}
|
||||
/>
|
||||
{/* main circle */}
|
||||
<circle cx={cp.x} cy={cp.y} r={CAT_CR}
|
||||
fill={cat.fill} stroke={cat.stroke}
|
||||
strokeWidth={isOpen ? 2.5 : 2}
|
||||
style={{ transition: 'stroke-width .25s' }}
|
||||
/>
|
||||
|
||||
{/* category name */}
|
||||
{cat.name.map((ln, ni) => (
|
||||
<text key={ni}
|
||||
x={cp.x} y={nameY0 + ni * nameLH}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
fontSize={15} fontWeight="700"
|
||||
fontFamily="Baloo 2, system-ui, sans-serif"
|
||||
fill={cat.stroke}
|
||||
>
|
||||
{ln}
|
||||
</text>
|
||||
))}
|
||||
{/* sub label */}
|
||||
{cat.sub.map((ln, si) => (
|
||||
<text key={si}
|
||||
x={cp.x} y={subY0 + si * subLH}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
fontSize={9} fontFamily="Inter, sans-serif"
|
||||
fill={cat.stroke} opacity={0.75}
|
||||
>
|
||||
{ln}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* open/close indicator dot */}
|
||||
<circle
|
||||
cx={cp.x} cy={cp.y + CAT_CR - 10}
|
||||
r={4}
|
||||
fill={isOpen ? cat.stroke : 'transparent'}
|
||||
stroke={cat.stroke}
|
||||
strokeWidth={1.2}
|
||||
style={{ transition: 'fill .25s' }}
|
||||
/>
|
||||
</motion.g>
|
||||
</motion.g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ── Center emblem ── */}
|
||||
<motion.g
|
||||
initial={prefersReduced ? undefined : { opacity: 0, scale: 0.5 }}
|
||||
whileInView={prefersReduced ? undefined : { opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true, margin: '-60px' }}
|
||||
transition={{ duration: 0.5, ease: [0.34, 1.56, 0.64, 1], delay: 0.5 }}
|
||||
style={{ transformOrigin: `${C}px ${C}px` }}
|
||||
>
|
||||
<circle cx={C} cy={C} r={58} fill="var(--cream)" stroke="var(--earth-200)" strokeWidth={1.5} />
|
||||
<circle cx={C} cy={C} r={52} fill="var(--warm-white)" />
|
||||
<image
|
||||
href="/assets/logo/anindhaloka-icon.png"
|
||||
x={C - 27} y={C - 27} width={54} height={54}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
/>
|
||||
</motion.g>
|
||||
</motion.svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, ChevronRight } from 'lucide-react';
|
||||
import type { Module } from '../data/modules';
|
||||
import EcosystemIcon from './EcosystemIcons';
|
||||
|
||||
interface Props {
|
||||
module: Module;
|
||||
}
|
||||
|
||||
export default function ModulePage({ module: m }: Props) {
|
||||
return (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section
|
||||
className="module-hero"
|
||||
style={{ background: `linear-gradient(150deg, ${m.gradientFrom} 0%, ${m.gradientTo} 60%, ${m.color}88 100%)` }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: `radial-gradient(ellipse 60% 70% at 80% 50%, ${m.color}22 0%, transparent 60%)`,
|
||||
}}
|
||||
/>
|
||||
<div className="container module-hero__content">
|
||||
<nav className="breadcrumb">
|
||||
<Link to="/">Beranda</Link>
|
||||
<ChevronRight size={12} />
|
||||
<Link to="/#ekosistem">Ekosistem</Link>
|
||||
<ChevronRight size={12} />
|
||||
<span style={{ color: 'rgba(255,255,255,.8)' }}>{m.name}</span>
|
||||
</nav>
|
||||
<div className="module-hero__tag">
|
||||
<EcosystemIcon id={m.id} size={20} withBackdrop={false} />
|
||||
{m.tagline}
|
||||
</div>
|
||||
<h1 className="module-hero__title">{m.name}</h1>
|
||||
<p className="module-hero__tagline">{m.description}</p>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<a href="#fitur" className="btn btn--primary">
|
||||
Lihat Program <ArrowRight size={16} />
|
||||
</a>
|
||||
<Link to="/kontak" className="btn btn--outline-light">
|
||||
Tanya Kami
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* About Module */}
|
||||
<section className="section" style={{ background: 'var(--cream)' }}>
|
||||
<div className="container" style={{ maxWidth: 760 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p className="eyebrow" style={{ color: m.color, marginBottom: 16 }}>Tentang {m.name}</p>
|
||||
<h2 style={{ marginBottom: 24 }}>
|
||||
{m.tagline}
|
||||
</h2>
|
||||
<p style={{ fontSize: '1.1rem', lineHeight: 1.85, color: 'var(--text-500)' }}>
|
||||
{m.longDesc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="section module-features" id="fitur">
|
||||
<div className="container">
|
||||
<div className="section__header">
|
||||
<p className="eyebrow" style={{ color: m.color }}>Program & Layanan</p>
|
||||
<h2>Apa yang kami tawarkan</h2>
|
||||
<p>Temukan berbagai program dan layanan yang dirancang untuk mendukung perjalananmu.</p>
|
||||
</div>
|
||||
<div className="grid-auto">
|
||||
{m.features.map((f, i) => (
|
||||
<div key={i} className="feature-card">
|
||||
<div
|
||||
className="feature-card__icon"
|
||||
style={{ background: `${m.color}18` }}
|
||||
>
|
||||
{f.icon}
|
||||
</div>
|
||||
<h4 className="feature-card__title">{f.title}</h4>
|
||||
<p className="feature-card__desc">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Banner */}
|
||||
<section
|
||||
className="section--sm"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${m.gradientFrom} 0%, ${m.color} 100%)`,
|
||||
padding: '56px 0',
|
||||
}}
|
||||
>
|
||||
<div className="container" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 24 }}>
|
||||
<div>
|
||||
<h3 style={{ color: '#fff', marginBottom: 8 }}>Siap memulai perjalananmu?</h3>
|
||||
<p style={{ color: 'rgba(255,255,255,.7)', maxWidth: 440 }}>
|
||||
Bergabunglah bersama komunitas {m.name} dan rasakan perbedaannya.
|
||||
</p>
|
||||
{m.contact && (
|
||||
<p style={{ color: 'rgba(255,255,255,.6)', fontSize: '.85rem', marginTop: 10 }}>
|
||||
{m.contact.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Link to="/kontak" className="btn btn--outline-light">
|
||||
Hubungi Kami
|
||||
</Link>
|
||||
<Link to="/temannin" className="btn" style={{ background: '#fff', color: m.color, borderColor: '#fff' }}>
|
||||
Daftar Sekarang <ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Other modules */}
|
||||
<section className="section" style={{ background: 'var(--warm-white)' }}>
|
||||
<div className="container">
|
||||
<div className="section__header" style={{ marginBottom: 32 }}>
|
||||
<p className="eyebrow">Ekosistem Anindhaloka</p>
|
||||
<h2>Jelajahi modul lainnya</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
{['/bhumi-anindha', '/paon', '/kaind', '/citta-rahayu', '/pasar-bahagia', '/learnnin', '/lokanin', '/temannin']
|
||||
.filter(p => p !== m.path)
|
||||
.map(p => {
|
||||
const mod = { '/bhumi-anindha': { icon: '🌱', name: 'Bhumi Anindha' }, '/paon': { icon: '🍽️', name: 'PAON' }, '/kaind': { icon: '🧵', name: 'KaIND' }, '/citta-rahayu': { icon: '🧘', name: 'Citta Rahayu' }, '/pasar-bahagia': { icon: '🏪', name: 'Pasar Bahagia' }, '/learnnin': { icon: '📚', name: 'LearnNIN' }, '/lokanin': { icon: '🏡', name: 'LokaNIN' }, '/temannin': { icon: '🤝', name: 'TemanNIN' } }[p];
|
||||
if (!mod) return null;
|
||||
return (
|
||||
<Link key={p} to={p} className="btn btn--outline" style={{ gap: 8, fontSize: '.875rem' }}>
|
||||
<span>{mod.icon}</span> {mod.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Menu, X, ChevronDown, ArrowRight } from 'lucide-react';
|
||||
import modules from '../data/modules';
|
||||
import { ModuleMark } from './EcosystemIcons';
|
||||
|
||||
export default function Navbar() {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [dropOpen, setDropOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const isHome = location.pathname === '/';
|
||||
const dropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 60);
|
||||
window.addEventListener('scroll', onScroll);
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setMenuOpen(false);
|
||||
setDropOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = menuOpen ? 'hidden' : '';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [menuOpen]);
|
||||
|
||||
const transparent = isHome && !scrolled;
|
||||
|
||||
return (
|
||||
<nav className={`navbar ${!transparent ? 'navbar--solid' : ''}`}>
|
||||
<div className="navbar__container">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="navbar__logo">
|
||||
<img
|
||||
src="/assets/logo/anindhaloka-icon.png"
|
||||
alt="Anindhaloka"
|
||||
style={{ height: 32, width: 'auto', display: 'block' }}
|
||||
/>
|
||||
<span
|
||||
className="navbar__logo-text"
|
||||
style={transparent ? { color: '#fff' } : {}}
|
||||
>
|
||||
anindhaloka
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<div className="navbar__nav">
|
||||
<Link to="/" className="navbar__link" style={transparent ? { color: 'rgba(255,255,255,.78)' } : {}}>
|
||||
Beranda
|
||||
</Link>
|
||||
<Link to="/tentang" className="navbar__link" style={transparent ? { color: 'rgba(255,255,255,.78)' } : {}}>
|
||||
Tentang
|
||||
</Link>
|
||||
|
||||
<div
|
||||
ref={dropRef}
|
||||
className="navbar__dropdown"
|
||||
onMouseEnter={() => setDropOpen(true)}
|
||||
onMouseLeave={() => setDropOpen(false)}
|
||||
>
|
||||
<button
|
||||
className="navbar__link"
|
||||
style={transparent ? { color: 'rgba(255,255,255,.78)' } : {}}
|
||||
>
|
||||
Ekosistem
|
||||
<ChevronDown
|
||||
size={13}
|
||||
style={{ transition: 'transform .2s', transform: dropOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{dropOpen && (
|
||||
<div className="navbar__dropdown-menu">
|
||||
{modules.map(m => (
|
||||
<Link key={m.id} to={m.path} className="navbar__dropdown-item">
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<ModuleMark id={m.id} size={26} />
|
||||
<span>
|
||||
<span className="navbar__dropdown-name">{m.name}</span>
|
||||
<span className="navbar__dropdown-desc">{m.tagline}</span>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link to="/kontak" className="navbar__link" style={transparent ? { color: 'rgba(255,255,255,.78)' } : {}}>
|
||||
Kontak
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/bergabung"
|
||||
className="btn btn--primary navbar__cta"
|
||||
style={{ fontSize: '.85rem', padding: '9px 20px' }}
|
||||
>
|
||||
Bergabung
|
||||
</Link>
|
||||
|
||||
<button
|
||||
className="navbar__hamburger"
|
||||
onClick={() => setMenuOpen(v => !v)}
|
||||
style={transparent ? { color: '#fff' } : {}}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{menuOpen ? <X size={22} /> : <Menu size={22} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{menuOpen && (
|
||||
<div className="navbar__mobile">
|
||||
<Link to="/" className="navbar__mobile-link">Beranda</Link>
|
||||
<Link to="/tentang" className="navbar__mobile-link">Tentang</Link>
|
||||
<div>
|
||||
<span className="navbar__mobile-section-title">Ekosistem</span>
|
||||
{modules.map(m => (
|
||||
<Link key={m.id} to={m.path} className="navbar__mobile-link navbar__mobile-link--sub" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ModuleMark id={m.id} size={20} />{m.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Link to="/kontak" className="navbar__mobile-link">Kontak</Link>
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<Link to="/bergabung" className="btn btn--primary" style={{ width: '100%', justifyContent: 'center' }}>
|
||||
Bergabung <ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
/** Overlay transisi singkat saat pindah halaman — logo Anindhaloka muncul sekilas. */
|
||||
export default function PageTransition() {
|
||||
const location = useLocation();
|
||||
const prefersReduced = useReducedMotion();
|
||||
const [show, setShow] = useState(false);
|
||||
const firstRender = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (firstRender.current) {
|
||||
firstRender.current = false;
|
||||
return;
|
||||
}
|
||||
setShow(true);
|
||||
const t = setTimeout(() => setShow(false), prefersReduced ? 200 : 650);
|
||||
return () => clearTimeout(t);
|
||||
}, [location.pathname, prefersReduced]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: prefersReduced ? 0.15 : 0.3 }}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 999,
|
||||
background: 'var(--maroon-950)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<motion.img
|
||||
src="/assets/logo/anindhaloka-icon.png"
|
||||
alt=""
|
||||
initial={prefersReduced ? undefined : { opacity: 0, scale: 0.6 }}
|
||||
animate={prefersReduced ? undefined : { opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.45, ease: [0.34, 1.56, 0.64, 1] }}
|
||||
style={{ width: 64, height: 'auto', display: 'block' }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { motion, useReducedMotion, type Variants } from 'framer-motion';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
interface RevealProps {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const variants: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.96, y: 24 },
|
||||
show: { opacity: 1, scale: 1, y: 0 },
|
||||
};
|
||||
|
||||
/** Wrapper scroll-reveal — elemen "tumbuh" (scale + translateY) saat masuk viewport. */
|
||||
export default function Reveal({ children, delay = 0, className, style }: RevealProps) {
|
||||
const prefersReduced = useReducedMotion();
|
||||
|
||||
if (prefersReduced) {
|
||||
return <div className={className} style={style}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
style={style}
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
variants={variants}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1], delay }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useRef } from 'react';
|
||||
import type { CSSProperties, PointerEvent, ReactNode } from 'react';
|
||||
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from 'framer-motion';
|
||||
|
||||
interface TiltCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/** Tilt 3D lembut mengikuti posisi pointer + lift saat hover — dipakai untuk kartu inisiatif. */
|
||||
export default function TiltCard({ children, className, style, max = 6 }: TiltCardProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const prefersReduced = useReducedMotion();
|
||||
|
||||
const px = useMotionValue(0.5);
|
||||
const py = useMotionValue(0.5);
|
||||
const springConfig = { stiffness: 200, damping: 20, mass: 0.5 };
|
||||
const sx = useSpring(px, springConfig);
|
||||
const sy = useSpring(py, springConfig);
|
||||
|
||||
const rotateX = useTransform(sy, [0, 1], [max, -max]);
|
||||
const rotateY = useTransform(sx, [0, 1], [-max, max]);
|
||||
|
||||
function handleMove(e: PointerEvent<HTMLDivElement>) {
|
||||
if (prefersReduced || !ref.current || e.pointerType === 'touch') return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
px.set((e.clientX - rect.left) / rect.width);
|
||||
py.set((e.clientY - rect.top) / rect.height);
|
||||
}
|
||||
|
||||
function handleLeave() {
|
||||
px.set(0.5);
|
||||
py.set(0.5);
|
||||
}
|
||||
|
||||
if (prefersReduced) {
|
||||
return <div className={className} style={style}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={{ ...style, rotateX, rotateY, transformPerspective: 800 }}
|
||||
onPointerMove={handleMove}
|
||||
onPointerLeave={handleLeave}
|
||||
whileHover={{ y: -8, scale: 1.015 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 22 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
export interface Module {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
tagline: string;
|
||||
description: string;
|
||||
longDesc: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
colorBg: string;
|
||||
gradientFrom: string;
|
||||
gradientTo: string;
|
||||
features: { icon: string; title: string; desc: string }[];
|
||||
contact?: string[];
|
||||
/** Logo resmi modul (jika sudah tersedia) — dipakai di kartu ekosistem menggantikan ikon custom. */
|
||||
logoImage?: string;
|
||||
}
|
||||
|
||||
const modules: Module[] = [
|
||||
{
|
||||
id: 'bhumi-anindha',
|
||||
path: '/bhumi-anindha',
|
||||
name: 'Bhumi Anindha',
|
||||
tagline: 'Pangan Lestari',
|
||||
description: 'Berkebun di Kota, Berbagi Kehidupan — akar dari meja makan lewat pertanian regeneratif perkotaan.',
|
||||
longDesc: 'Bhumi Anindha adalah undangan untuk kembali, terhubung, dan tumbuh bersama bumi yang sama. Kami percaya kota bisa menjadi tempat yang menghidupkan, bukan menguras — lewat pertanian regeneratif yang menghormati keselarasan dengan alam, gotong royong, kedaulatan pangan, dan kesehatan holistik. Setiap kebun yang kami rawat, dari kebun dapur hingga hutan pangan, dibangun dengan satu prinsip: tidak ada yang terbuang, semua kembali.',
|
||||
icon: '🌱',
|
||||
color: '#4A7C59',
|
||||
colorBg: '#E8F2E4',
|
||||
gradientFrom: '#1A3A1F',
|
||||
gradientTo: '#2D5016',
|
||||
features: [
|
||||
{ icon: '🌾', title: 'Kebun Lestari', desc: 'Kebun dapur, kebun produksi, hutan pangan, kolam ikan, dan dapur komunal — dengan satu prinsip: tidak ada yang terbuang, semua kembali.' },
|
||||
{ icon: '📋', title: 'Program Bhumi', desc: 'Ruang Hidup Lestari, Belajar Lestari, Komunitas Lestari, Pasar Bahagia (CSA), dan Jasa Lestari — lima jalur program yang saling menopang.' },
|
||||
{ icon: '🌿', title: 'Keselarasan dengan Alam', desc: 'Pertanian regeneratif yang menempatkan alam sebagai mitra, bukan sumber daya untuk dieksploitasi.' },
|
||||
{ icon: '🤝', title: 'Gotong Royong & Kedaulatan Pangan', desc: 'Kebun sebagai ruang belajar bersama, dibangun atas inklusivitas dan keadilan pangan.' },
|
||||
{ icon: '🙌', title: 'Cara Bergabung', desc: 'Jadi relawan, anggota CSA, peserta lokakarya, atau tamu berkebun akhir pekan — semua pintu terbuka.' },
|
||||
],
|
||||
contact: ['IG @bhumi.anindha', '+123-456-7890 (kontak sedang diperbarui)'],
|
||||
logoImage: '/assets/logo/ekosistem/bhumi-anindha/mark.png',
|
||||
},
|
||||
{
|
||||
id: 'paon',
|
||||
path: '/paon',
|
||||
name: 'PAON',
|
||||
tagline: 'Olah Rasa',
|
||||
description: 'Good Food · Great Taste — dapur sebagai ruang penyatuan, gastronomi Nusantara dengan teknik global dan jiwa lokal.',
|
||||
longDesc: 'PAON lahir dari keresahan nyata terhadap krisis pangan urban, dan menjawabnya dengan makanan yang lezat, bercerita, dan berpengalaman penuh makna. Kami berpijak pada tiga akar: Good Food yang baik untuk tubuh manusia dan bumi, Gastronomy Glocal dengan teknik global dan jiwa lokal, serta perpaduan gastronomi, sains, dan kearifan lokal. Lima makna Good Food kami hidupkan lewat setiap hidangan: healing cuisine, low carb by design, inclusive by default, wholesome & nourishing, dan responsible sourcing.',
|
||||
icon: '🍽️',
|
||||
color: '#C4693A',
|
||||
colorBg: '#FAF0EA',
|
||||
gradientFrom: '#5C2810',
|
||||
gradientTo: '#8B3D1A',
|
||||
features: [
|
||||
{ icon: '🍲', title: 'Rasa', desc: 'All-Day Dining, 10.00–21.00 WIB, dengan 36 kursi (22 indoor + 14 outdoor).' },
|
||||
{ icon: '🎪', title: 'Pasar Bahagia Pop-Up', desc: 'Partisipasi rutin di setiap penyelenggaraan Pasar Bahagia.' },
|
||||
{ icon: '🛍️', title: 'Warung Lestari', desc: 'Curated retail store — produk fermentasi dan lokal, masing-masing dengan kartu cerita.' },
|
||||
{ icon: '🧺', title: 'Piknik Bahagia', desc: 'Garden dining experience di ruang terbuka.' },
|
||||
{ icon: '👨🍳', title: 'Olah Rasa', desc: 'Workshop & kelas memasak, 2–3 jam, maksimal 12 peserta.' },
|
||||
{ icon: '🍷', title: 'Cerita Rasa', desc: 'Thematic dinner 5–7 course, maksimal 20 tamu, sebulan sekali.' },
|
||||
],
|
||||
logoImage: '/assets/logo/ekosistem/paon/mark.png',
|
||||
},
|
||||
{
|
||||
id: 'kaind',
|
||||
path: '/kaind',
|
||||
name: 'KaIND',
|
||||
tagline: 'Sandang Berkesadaran',
|
||||
description: 'Menghidupkan Serat, Menenun Masa Depan — pakaian sebagai pernyataan nilai, dari serat dan pewarna alami.',
|
||||
longDesc: 'KaIND menenun perjalanan sandang dari ladang ke helai kain — bersama petani serat alam di Bali, Tuban, Wonosobo, dan Subang, ditenun oleh perajin ATBM di Pasuruan, Bali, dan Pekalongan. Warna-warna kami lahir dari bumi: indigo, mengkudu, kulit kayu, dan rempah lokal. Ini adalah ekosistem, bukan sekadar produk — melibatkan petani, pengrajin, perempuan pelaku produksi, dan konsumen yang sadar akan pilihannya.',
|
||||
icon: '🧵',
|
||||
color: '#5B6E8E',
|
||||
colorBg: '#EEF1F7',
|
||||
gradientFrom: '#1E2D40',
|
||||
gradientTo: '#2E4560',
|
||||
features: [
|
||||
{ icon: '🌾', title: 'Kain Serat Alami', desc: 'Dari petani di Bali, Tuban, Wonosobo, dan Subang, ditenun ATBM oleh perajin di Pasuruan, Bali, dan Pekalongan.' },
|
||||
{ icon: '👗', title: 'Fashion', desc: 'Pakaian yang bercerita, dibuat dari kain bernyawa.' },
|
||||
{ icon: '👜', title: 'Aksesori', desc: 'Pelengkap yang menghidupkan setiap detail penampilan.' },
|
||||
{ icon: '🏠', title: 'Interior & Home Living', desc: 'Sentuhan serat alami untuk ruang tinggal.' },
|
||||
{ icon: '✂️', title: 'Made to Order', desc: 'Pesan pakaian yang dibuat khusus sesuai ukuran dan pilihan kain Anda.' },
|
||||
{ icon: '🎨', title: 'Warna dari Bumi', desc: 'Pewarna alami dari indigo, mengkudu, kulit kayu, dan rempah lokal.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'citta-rahayu',
|
||||
path: '/citta-rahayu',
|
||||
name: 'Citta Rahayu',
|
||||
tagline: 'Olah Diri Berkesadaran',
|
||||
description: 'Ruang pulang ke diri sendiri — yoga, meditasi, singing bowl, dan ragam praktik olah diri lainnya.',
|
||||
longDesc: 'Citta Rahayu merawat tiga dimensi kemanusiaan: Badan Kasar lewat gerak, sentuhan, dan napas; Badan Halus lewat meditasi, suara, dan keheningan; serta Badan Penyebab, kepenuhan yang paling mendasar dalam diri. Di sinilah setiap orang diundang untuk pulang ke dirinya sendiri, lewat ragam praktik yang menyeimbangkan tubuh, pikiran, dan jiwa.',
|
||||
icon: '🧘',
|
||||
color: '#7C5E9E',
|
||||
colorBg: '#F2EEF7',
|
||||
gradientFrom: '#2E1A45',
|
||||
gradientTo: '#4A2E6B',
|
||||
features: [
|
||||
{ icon: '🧘', title: 'Ragam Praktik', desc: 'Yoga, Meditasi, Sound Bathing, Singing Bowl, Dance of Universal Peace, Tai Chi, Body Massage, dan Tuina.' },
|
||||
{ icon: '🌤️', title: 'Singgah', desc: 'Kegiatan musiman untuk yang ingin mampir dan merasakan sejenak.' },
|
||||
{ icon: '🌱', title: 'Rawat', desc: 'Praktik berkala untuk membangun kebiasaan merawat diri secara konsisten.' },
|
||||
{ icon: '👥', title: 'Kumpul', desc: 'Pertemuan komunitas untuk berbagi dan tumbuh bersama.' },
|
||||
{ icon: '🏔️', title: 'Pulang', desc: 'Retreat mendalam untuk pemulihan dan penyatuan diri yang utuh.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pasar-bahagia',
|
||||
path: '/pasar-bahagia',
|
||||
name: 'Pasar Bahagia',
|
||||
tagline: 'Ekosistem Kolaboratif',
|
||||
description: 'Pasar lokal untuk membangun gaya hidup lestari berbasis komunitas, dalam spirit gotong royong.',
|
||||
longDesc: 'Pasar Bahagia adalah perayaan gotong royong — ruang tempat produsen lokal, pengrajin, dan konsumen sadar bertemu dalam ekosistem yang adil dan menyenangkan. Kami percaya pada lima nilai: Lokal, Inklusif, Budaya Nusantara, Lestari, dan Rahayu. Digelar sejak 2023, Pasar Bahagia kini menjadi rumah bagi puluhan jenama lokal dan ribuan kunjungan komunitas.',
|
||||
icon: '🏪',
|
||||
color: '#C4803A',
|
||||
colorBg: '#FAF2EA',
|
||||
gradientFrom: '#3D2010',
|
||||
gradientTo: '#6B3A15',
|
||||
features: [
|
||||
{ icon: '📅', title: 'Digelar Sejak 2023', desc: 'Dimulai dengan 37 jenama, kini rutin digelar dua bulan sekali.' },
|
||||
{ icon: '🏪', title: '60+ Jenama Lokal', desc: 'Bagian dari Komunitas Kreator Bahagia yang terus bertumbuh.' },
|
||||
{ icon: '🎓', title: '50+ Kegiatan Edukasi', desc: 'Workshop dan sesi belajar yang telah terselenggara bersama komunitas.' },
|
||||
{ icon: '👣', title: '2.000+ Kunjungan TemanNIN', desc: 'Jejak komunitas yang terus hadir dan bertumbuh bersama Pasar Bahagia.' },
|
||||
{ icon: '📣', title: 'Jadi Storyteller', desc: 'Bagikan cerita dan dokumentasikan perjalanan Pasar Bahagia.' },
|
||||
{ icon: '🕸️', title: 'Perluas Jejaring', desc: 'Hubungkan Pasar Bahagia dengan komunitas dan mitra baru.' },
|
||||
{ icon: '💰', title: 'Akses Pendanaan', desc: 'Dukung keberlanjutan program lewat kolaborasi pendanaan.' },
|
||||
],
|
||||
contact: ['+62 815 7330 5808 (Welin Cakra)', '@anindhaloka_id', '@kreasi.nin'],
|
||||
logoImage: '/assets/logo/ekosistem/pasar-bahagia/mark.png',
|
||||
},
|
||||
{
|
||||
id: 'learnnin',
|
||||
path: '/learnnin',
|
||||
name: 'LearnNIN',
|
||||
tagline: 'Ruang Belajar Berkesadaran',
|
||||
description: 'Payung program riset, pembelajaran, dan kreativitas — pembelajaran sebagai perjalanan, bukan ujian.',
|
||||
longDesc: 'LearnNIN adalah payung yang menaungi seluruh program pembelajaran Anindhaloka, dijalani dengan semangat riset, pencerahan, dan kreativitas. Setiap program adalah undangan untuk menjelajahi satu dimensi kehidupan: seni, alam, komunitas, dan transformasi diri — karena bagi kami, pembelajaran adalah perjalanan, bukan ujian.',
|
||||
icon: '📚',
|
||||
color: '#3A8B8B',
|
||||
colorBg: '#E8F5F5',
|
||||
gradientFrom: '#0F2E2E',
|
||||
gradientTo: '#1C4D4D',
|
||||
features: [
|
||||
{ icon: '🌱', title: 'Ruang yang Sedang Tumbuh', desc: 'Daftar program LearnNIN sedang kami tumbuhkan bersama komunitas. Hubungi kami untuk info terbaru atau mengusulkan kolaborasi program.' },
|
||||
],
|
||||
logoImage: '/assets/logo/ekosistem/learnin/mark.png',
|
||||
},
|
||||
{
|
||||
id: 'lokanin',
|
||||
path: '/lokanin',
|
||||
name: 'LokaNIN',
|
||||
tagline: 'Ruang Temu Komunitas',
|
||||
description: 'Ruang fisik dan energetik tempat komunitas berakar — ruang yang hidup adalah ruang yang dihuni.',
|
||||
longDesc: 'LokaNIN adalah ruang fisik sekaligus energetik yang dibuka untuk komunitas dan mitra menggelar kegiatan sendiri — diskusi, lokakarya, pameran, latihan rutin, hingga pertemuan. Kami percaya ruang yang hidup adalah ruang yang dihuni, tempat setiap pertemuan bisa menjadi awal dari sebuah gerakan.',
|
||||
icon: '🏡',
|
||||
color: '#9E5A4A',
|
||||
colorBg: '#F7EEEC',
|
||||
gradientFrom: '#2D1510',
|
||||
gradientTo: '#5C2A20',
|
||||
features: [
|
||||
{ icon: '🏛️', title: 'Booking Ruang', desc: 'Pesan ruang untuk diskusi, lokakarya, pameran, latihan rutin, atau pertemuan komunitas.' },
|
||||
{ icon: '🎪', title: 'Event Komunitas', desc: 'Kalender kegiatan yang digelar bersama komunitas dan mitra LokaNIN.' },
|
||||
{ icon: '🤝', title: 'Ruang Kolaborasi', desc: 'Ruang yang terbuka bagi siapa saja yang ingin berkarya dan berjejaring bersama.' },
|
||||
{ icon: '📸', title: 'Ruang yang Sedang Tumbuh', desc: 'Nama ruang, foto, dan contoh kegiatan sedang kami lengkapi bersama komunitas. Hubungi kami untuk info dan ketersediaan terkini.' },
|
||||
],
|
||||
contact: ['+62 815 7330 5808 (Welin Cakra)', '@anindhaloka_id', '@kreasi.nin'],
|
||||
},
|
||||
{
|
||||
id: 'temannin',
|
||||
path: '/temannin',
|
||||
name: 'TemanNIN',
|
||||
tagline: 'Jejaring Anindhaloka',
|
||||
description: 'Lingkaran anggota dan mitra yang terus melebar — individu, komunitas, usaha, pemerintah, LSM, dan media.',
|
||||
longDesc: 'TemanNIN adalah jejaring anggota dan mitra Anindhaloka yang terus melebar — terbuka untuk individu, komunitas, usaha, pemerintah, LSM, dan media. Menjadi TemanNIN bukan soal mendaftar dan mendapatkan kartu anggota; ini soal memilih untuk terhubung dengan nilai, komunitas, dan gerakan yang sedang tumbuh.',
|
||||
icon: '🤝',
|
||||
color: '#8B7540',
|
||||
colorBg: '#F5F0E0',
|
||||
gradientFrom: '#2A2010',
|
||||
gradientTo: '#4A3A1A',
|
||||
features: [
|
||||
{ icon: '🌱', title: 'Bukan Sekadar Kartu Anggota', desc: 'Menjadi TemanNIN berarti memilih untuk terhubung dengan nilai, komunitas, dan gerakan yang sedang tumbuh.' },
|
||||
{ icon: '🔗', title: 'Lingkaran yang Melebar', desc: 'Jaringan anggota dan mitra terbuka yang terus berkembang bersama ekosistem Anindhaloka.' },
|
||||
{ icon: '🌾', title: 'Terbuka untuk Semua', desc: 'Individu, komunitas, usaha, pemerintah, LSM, hingga media — semua punya tempat di TemanNIN.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default modules;
|
||||
@@ -0,0 +1,849 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap');
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
/* ── Brand Primary — Plum (dari logo asli) ── */
|
||||
--maroon-950: #1E1216;
|
||||
--maroon-900: #2F1D24;
|
||||
--maroon-800: #4A2E38;
|
||||
--maroon-700: #5B3A46;
|
||||
--maroon-600: #734B59;
|
||||
--maroon-500: #916272;
|
||||
--maroon-400: #B08492;
|
||||
--maroon-300: #CCA8B2;
|
||||
--maroon-200: #E3CCD2;
|
||||
--maroon-100: #F3E5E8;
|
||||
--maroon-50: #FAF2F4;
|
||||
|
||||
/* ── Forest — Hijau Zaitun (hero gelap Tentang/Kontak/Bergabung) ── */
|
||||
--forest-950: #141C0E;
|
||||
--forest-900: #1E2A16;
|
||||
--forest-800: #2B3D20;
|
||||
--forest-700: #3C5429;
|
||||
--forest-600: #4F6B3A;
|
||||
--forest-500: #6C8659;
|
||||
--forest-400: #8FA57C;
|
||||
--forest-300: #B2C2A0;
|
||||
--forest-200: #D2DCC4;
|
||||
--forest-100: #E9EEE0;
|
||||
--forest-50: #F5F7F0;
|
||||
|
||||
/* ── Logo Icon Accent Colors ── */
|
||||
--amber: #F0B429;
|
||||
--amber-light: #FDE68A;
|
||||
--coral: #D4552B;
|
||||
--coral-light: #F2B49E;
|
||||
--terracotta: #D4552B;
|
||||
--sage: #4F6B3A;
|
||||
--sage-light: #AEBE8E;
|
||||
|
||||
/* ── Warm Earth / Neutral ── */
|
||||
--earth-800: #63351A;
|
||||
--earth-700: #93583A;
|
||||
--earth-600: #B37048;
|
||||
--earth-500: #CC8C5E;
|
||||
--earth-400: #D9A468;
|
||||
--earth-300: #E4C48F;
|
||||
--earth-200: #EDD9B5;
|
||||
--earth-100: #F5EDD8;
|
||||
--earth-50: #FDF9F3;
|
||||
|
||||
--cream: #F2ECDD;
|
||||
--warm-white: #FBF7EE;
|
||||
|
||||
--text-900: #2A2520;
|
||||
--text-700: #3A342E;
|
||||
--text-500: #5A524A;
|
||||
--text-300: #8A8076;
|
||||
--text-100: #C0B8AE;
|
||||
|
||||
--font-heading: 'Baloo 2', 'Poppins', system-ui, sans-serif;
|
||||
--font-body: 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', monospace;
|
||||
|
||||
--max-w: 1200px;
|
||||
--nav-h: 72px;
|
||||
|
||||
--r-sm: 6px;
|
||||
--r-md: 12px;
|
||||
--r-lg: 20px;
|
||||
--r-xl: 32px;
|
||||
--r-full: 9999px;
|
||||
|
||||
--sh-sm: 0 1px 4px rgba(0,0,0,.06);
|
||||
--sh-md: 0 4px 16px rgba(0,0,0,.08), 0 2px 4px rgba(0,0,0,.04);
|
||||
--sh-lg: 0 12px 40px rgba(0,0,0,.10), 0 4px 8px rgba(0,0,0,.05);
|
||||
--sh-xl: 0 24px 64px rgba(0,0,0,.12), 0 8px 16px rgba(0,0,0,.06);
|
||||
|
||||
--ease: cubic-bezier(.4,0,.2,1);
|
||||
--ease-organic: cubic-bezier(.22,1,.36,1);
|
||||
--ease-bounce: cubic-bezier(.34,1.56,.64,1);
|
||||
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
color: var(--text-500);
|
||||
background: var(--warm-white);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body { margin: 0; }
|
||||
img, svg { display: block; max-width: 100%; }
|
||||
a { text-decoration: none; color: inherit; }
|
||||
button { cursor: pointer; font-family: inherit; }
|
||||
ul { list-style: none; }
|
||||
|
||||
#root { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
/* ─── Typography ─────────────────────────────────────────── */
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--text-900);
|
||||
line-height: 1.15;
|
||||
font-weight: 600;
|
||||
}
|
||||
h1 { font-size: clamp(2.4rem, 5vw, 4rem); }
|
||||
h2 { font-size: clamp(1.8rem, 3.5vw, 2.8rem); }
|
||||
h3 { font-size: clamp(1.3rem, 2.5vw, 1.8rem); }
|
||||
h4 { font-size: 1.15rem; font-weight: 600; }
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: .72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--maroon-600);
|
||||
}
|
||||
.eyebrow--light { color: rgba(255,255,255,.55); }
|
||||
.eyebrow--amber { color: var(--amber); }
|
||||
|
||||
/* ─── Layout ─────────────────────────────────────────────── */
|
||||
.container { width: 100%; max-width: var(--max-w); margin-inline: auto; padding-inline: 24px; }
|
||||
|
||||
.section { padding-block: 80px; }
|
||||
.section--lg { padding-block: 112px; }
|
||||
.section--sm { padding-block: 48px; }
|
||||
|
||||
.section__header { text-align: center; margin-bottom: 56px; }
|
||||
.section__header p { margin-top: 16px; font-size: 1.05rem; color: var(--text-300); max-width: 540px; margin-inline: auto; }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
|
||||
.grid-auto {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.grid-auto > * {
|
||||
flex: 1 1 280px;
|
||||
max-width: calc((100% - 48px) / 3);
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.grid-auto > * { max-width: calc((100% - 24px) / 2); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.grid-auto > * { flex: 0 0 100%; max-width: 100%; }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.grid-4 { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-3 { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
|
||||
.section { padding-block: 56px; }
|
||||
.section--lg { padding-block: 72px; }
|
||||
}
|
||||
|
||||
/* ─── Buttons ─────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 12px 28px; border-radius: var(--r-full);
|
||||
font-weight: 700; font-size: .9rem; letter-spacing: .02em;
|
||||
transition: transform .35s var(--ease-bounce), background-color .25s var(--ease), border-color .25s var(--ease), box-shadow .25s var(--ease), color .25s var(--ease);
|
||||
border: 2px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--terracotta); color: #fff; border-color: var(--terracotta);
|
||||
}
|
||||
.btn--primary:hover {
|
||||
background: #B8461F; border-color: #B8461F;
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 8px 24px rgba(212,85,43,.4);
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
background: transparent; color: var(--maroon-700); border-color: var(--maroon-700);
|
||||
}
|
||||
.btn--outline:hover {
|
||||
background: var(--maroon-700); color: #fff; transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn--outline-light {
|
||||
background: transparent; color: #fff; border-color: rgba(255,255,255,.55);
|
||||
}
|
||||
.btn--outline-light:hover {
|
||||
background: rgba(255,255,255,.14); border-color: #fff;
|
||||
}
|
||||
|
||||
.btn--amber {
|
||||
background: var(--amber); color: var(--maroon-900); border-color: var(--amber);
|
||||
}
|
||||
.btn--amber:hover {
|
||||
background: #D9961A; border-color: #D9961A;
|
||||
transform: translateY(-1px); box-shadow: 0 8px 24px rgba(240,180,41,.4);
|
||||
}
|
||||
|
||||
.btn--earth {
|
||||
background: var(--earth-700); color: #fff; border-color: var(--earth-700);
|
||||
}
|
||||
.btn--earth:hover {
|
||||
background: var(--earth-800); border-color: var(--earth-800);
|
||||
transform: translateY(-1px); box-shadow: 0 8px 24px rgba(139,94,60,.35);
|
||||
}
|
||||
|
||||
.btn--lg { padding: 16px 40px; font-size: 1rem; }
|
||||
.btn--sm { padding: 8px 20px; font-size: .825rem; }
|
||||
|
||||
/* ─── Navbar ─────────────────────────────────────────────── */
|
||||
.navbar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
height: var(--nav-h);
|
||||
transition: background .3s var(--ease), box-shadow .3s var(--ease);
|
||||
}
|
||||
|
||||
.navbar--solid {
|
||||
background: rgba(253,250,245,.97);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,.07);
|
||||
}
|
||||
|
||||
.navbar__container {
|
||||
max-width: var(--max-w); margin-inline: auto; padding-inline: 24px;
|
||||
height: 100%; display: flex; align-items: center; gap: 40px;
|
||||
}
|
||||
|
||||
.navbar__logo {
|
||||
flex-shrink: 0; display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
|
||||
.navbar__logo-text {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -.02em;
|
||||
color: var(--maroon-800);
|
||||
}
|
||||
|
||||
.navbar__nav {
|
||||
display: flex; align-items: center; gap: 4px; flex: 1;
|
||||
}
|
||||
|
||||
.navbar__link {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 8px 12px; border-radius: var(--r-sm);
|
||||
font-size: .875rem; font-weight: 600;
|
||||
color: var(--text-700); background: none; border: none;
|
||||
transition: color .2s, background .2s;
|
||||
}
|
||||
.navbar__link:hover { color: var(--maroon-700); background: var(--maroon-50); }
|
||||
|
||||
.navbar__dropdown { position: relative; }
|
||||
|
||||
.navbar__dropdown-menu {
|
||||
position: absolute; top: calc(100% + 8px); left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #fff; border-radius: var(--r-md); box-shadow: var(--sh-xl);
|
||||
padding: 8px; min-width: 700px;
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 2px;
|
||||
border: 1px solid var(--earth-100);
|
||||
}
|
||||
|
||||
.navbar__dropdown-item {
|
||||
display: block; padding: 10px 14px; border-radius: var(--r-sm);
|
||||
transition: background .2s;
|
||||
}
|
||||
.navbar__dropdown-item:hover { background: var(--maroon-50); }
|
||||
.navbar__dropdown-name { display: block; font-weight: 700; font-size: .875rem; color: var(--text-900); }
|
||||
.navbar__dropdown-desc { display: block; font-size: .75rem; color: var(--text-300); margin-top: 1px; }
|
||||
|
||||
.navbar__cta { margin-left: auto; flex-shrink: 0; }
|
||||
|
||||
.navbar__hamburger {
|
||||
display: none; background: none; border: none;
|
||||
color: var(--maroon-800); margin-left: auto; padding: 8px;
|
||||
}
|
||||
|
||||
.navbar__mobile {
|
||||
position: fixed; inset: var(--nav-h) 0 0 0;
|
||||
background: var(--warm-white); overflow-y: auto;
|
||||
padding: 16px 0 32px; display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.navbar__mobile-link {
|
||||
display: block; padding: 12px 24px; font-weight: 600;
|
||||
color: var(--text-700); border-bottom: 1px solid var(--earth-100);
|
||||
}
|
||||
.navbar__mobile-link--sub {
|
||||
padding-left: 40px; font-size: .875rem; color: var(--text-500); font-weight: 500;
|
||||
}
|
||||
|
||||
.navbar__mobile-section-title {
|
||||
display: block; padding: 12px 24px 8px;
|
||||
font-size: .72rem; font-weight: 800;
|
||||
text-transform: uppercase; letter-spacing: .12em; color: var(--maroon-600);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.navbar__nav, .navbar__cta { display: none; }
|
||||
.navbar__hamburger { display: flex; }
|
||||
}
|
||||
|
||||
/* ─── Hero ───────────────────────────────────────────────── */
|
||||
.hero {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(160deg, var(--maroon-950) 0%, var(--maroon-900) 35%, #3A2430 65%, #46283A 100%);
|
||||
display: flex; align-items: center; position: relative; overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 60% at 75% 50%, rgba(212,85,43,.14) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 40% 50% at 20% 80%, rgba(240,180,41,.09) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.hero__decor {
|
||||
position: absolute; right: -60px; top: 50%; transform: translateY(-50%);
|
||||
width: 560px; height: 560px; border-radius: 50%;
|
||||
border: 1px solid rgba(255,255,255,.05);
|
||||
}
|
||||
.hero__decor::before {
|
||||
content: ''; position: absolute; inset: 60px; border-radius: 50%;
|
||||
border: 1px solid rgba(255,255,255,.04);
|
||||
}
|
||||
.hero__decor::after {
|
||||
content: ''; position: absolute; inset: 120px; border-radius: 50%;
|
||||
border: 1px solid rgba(255,255,255,.03);
|
||||
}
|
||||
|
||||
.hero__content { position: relative; z-index: 1; padding-block: 120px 80px; }
|
||||
|
||||
.hero__title {
|
||||
font-size: clamp(2.8rem, 6vw, 5rem);
|
||||
font-weight: 700; color: #fff; line-height: 1.1;
|
||||
max-width: 680px; margin-bottom: 24px;
|
||||
}
|
||||
.hero__title em { font-style: italic; color: var(--amber-light); }
|
||||
|
||||
.hero__subtitle {
|
||||
font-size: 1.1rem; color: rgba(255,255,255,.68);
|
||||
max-width: 500px; line-height: 1.8; margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.hero__actions { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
|
||||
.hero__scroll {
|
||||
position: absolute; bottom: 40px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
color: rgba(255,255,255,.3); font-size: .7rem; letter-spacing: .12em; text-transform: uppercase;
|
||||
}
|
||||
.hero__scroll-line {
|
||||
width: 1px; height: 40px;
|
||||
background: linear-gradient(to bottom, rgba(255,255,255,.25), transparent);
|
||||
animation: scrollLine 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes scrollLine {
|
||||
0%, 100% { transform: scaleY(1); opacity: .35; }
|
||||
50% { transform: scaleY(.65); opacity: .7; }
|
||||
}
|
||||
|
||||
/* ─── Module Hero (inner pages) ─────────────────────────── */
|
||||
.module-hero {
|
||||
min-height: 56vh; display: flex; align-items: flex-end;
|
||||
padding-top: var(--nav-h); position: relative; overflow: hidden;
|
||||
}
|
||||
|
||||
.module-hero__content { position: relative; z-index: 1; padding-block: 64px 72px; }
|
||||
.module-hero__title { color: #fff; margin-bottom: 16px; }
|
||||
.module-hero__tagline { color: rgba(255,255,255,.65); font-size: 1.05rem; max-width: 480px; margin-bottom: 32px; }
|
||||
|
||||
.module-hero__tag {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: rgba(255,255,255,.12); backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,.18); border-radius: var(--r-full);
|
||||
padding: 6px 14px; font-size: .75rem; font-weight: 800;
|
||||
letter-spacing: .1em; text-transform: uppercase;
|
||||
color: rgba(255,255,255,.85); margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ─── Cards ──────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: #fff; border-radius: var(--r-lg); overflow: hidden;
|
||||
box-shadow: var(--sh-sm); border: 1px solid var(--earth-100);
|
||||
transition: transform .3s var(--ease), box-shadow .3s var(--ease);
|
||||
}
|
||||
.card:hover { transform: translateY(-6px); box-shadow: var(--sh-xl); }
|
||||
|
||||
.card--module { display: flex; flex-direction: column; }
|
||||
|
||||
/* ─── Ecosystem Mosaic Grid ──────────────────────────────── */
|
||||
.ecosystem-mosaic {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-auto-rows: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
.ecosystem-mosaic__item--featured { grid-column: span 2; }
|
||||
@media (max-width: 1024px) {
|
||||
.ecosystem-mosaic { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.ecosystem-mosaic { grid-template-columns: 1fr; }
|
||||
.ecosystem-mosaic__item--featured { grid-column: span 1; }
|
||||
}
|
||||
|
||||
/* ─── Card Cover (replaces icon) ────────────────────────── */
|
||||
.card__cover {
|
||||
height: 180px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card__cover--tall { height: 244px; }
|
||||
|
||||
/* Decorative organic blobs inside cover */
|
||||
.card__cover-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Icon at top-left */
|
||||
.card__cover-emoji {
|
||||
font-size: 2.6rem;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 18px;
|
||||
z-index: 2;
|
||||
filter: drop-shadow(0 4px 10px rgba(0,0,0,.18));
|
||||
transition: transform .3s var(--ease);
|
||||
line-height: 1;
|
||||
}
|
||||
.card:hover .card__cover-emoji { transform: scale(1.08); }
|
||||
|
||||
/* Badge putih untuk logo resmi modul (kontras terjaga di atas cover gelap apa pun warnanya) */
|
||||
.card__cover-logo-badge {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
background: #fff;
|
||||
border-radius: var(--r-lg);
|
||||
padding: 12px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.22);
|
||||
max-width: 76%;
|
||||
max-height: 68%;
|
||||
transition: transform .3s var(--ease);
|
||||
}
|
||||
.card__cover-logo-badge img { display: block; max-width: 100%; object-fit: contain; }
|
||||
.card:hover .card__cover-logo-badge { transform: scale(1.06); }
|
||||
|
||||
/* Tag at bottom of cover */
|
||||
.card__cover-tag {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
padding: 8px 18px;
|
||||
background: linear-gradient(to top, rgba(0,0,0,.35) 0%, transparent 100%);
|
||||
font-size: .65rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255,255,255,.9);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.card__body { padding: 20px 22px 20px; flex: 1; }
|
||||
.card__title { font-size: 1.2rem; margin-bottom: 8px; }
|
||||
.card__desc { font-size: .875rem; color: var(--text-300); line-height: 1.65; }
|
||||
|
||||
.card__footer {
|
||||
padding: 12px 22px 16px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.card__link {
|
||||
font-size: .875rem; font-weight: 700;
|
||||
display: flex; align-items: center; gap: 6px; transition: gap .2s;
|
||||
}
|
||||
.card:hover .card__link { gap: 10px; }
|
||||
|
||||
/* ─── Feature Card ───────────────────────────────────────── */
|
||||
.feature-card {
|
||||
padding: 28px 24px; border-radius: var(--r-lg);
|
||||
background: #fff; border: 1px solid var(--earth-100);
|
||||
transition: transform .3s var(--ease), box-shadow .3s var(--ease);
|
||||
}
|
||||
.feature-card:hover { transform: translateY(-3px); box-shadow: var(--sh-md); }
|
||||
.feature-card__icon {
|
||||
width: 48px; height: 48px; border-radius: var(--r-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.35rem; margin-bottom: 16px;
|
||||
}
|
||||
.feature-card__title { font-size: 1.05rem; margin-bottom: 8px; }
|
||||
.feature-card__desc { font-size: .875rem; color: var(--text-300); }
|
||||
|
||||
/* ─── Value Card ─────────────────────────────────────────── */
|
||||
.value-card { text-align: center; padding: 28px 16px; }
|
||||
.value-card__icon { font-size: 2.2rem; margin-bottom: 12px; }
|
||||
.value-card__title { font-size: 1.05rem; color: #fff; margin-bottom: 8px; font-family: var(--font-heading); }
|
||||
.value-card__desc { font-size: .82rem; color: rgba(255,255,255,.55); line-height: 1.55; }
|
||||
|
||||
/* ─── About Split ────────────────────────────────────────── */
|
||||
.about-split {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 80px; align-items: center;
|
||||
}
|
||||
.about-split__text h2 { margin-bottom: 20px; }
|
||||
.about-split__text p { margin-bottom: 16px; font-size: 1.05rem; line-height: 1.85; color: var(--text-500); }
|
||||
.about-split__visual { position: relative; display: flex; justify-content: center; }
|
||||
|
||||
.about-orb {
|
||||
width: 300px; height: 300px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--maroon-200), var(--earth-300));
|
||||
opacity: .25;
|
||||
}
|
||||
.about-orb-inner {
|
||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
width: 220px; height: 220px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--amber-light), var(--coral-light));
|
||||
opacity: .2;
|
||||
}
|
||||
|
||||
.about-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px; margin-top: 48px; }
|
||||
.about-stat {
|
||||
text-align: center; padding: 24px 16px; background: #fff; border-radius: var(--r-md);
|
||||
}
|
||||
.about-stat:first-child { border-radius: var(--r-md) 0 0 var(--r-md); }
|
||||
.about-stat:last-child { border-radius: 0 var(--r-md) var(--r-md) 0; }
|
||||
.about-stat__num { font-family: var(--font-mono); font-size: 2.2rem; font-weight: 600; color: var(--terracotta); display: block; }
|
||||
.about-stat__label { font-size: .78rem; color: var(--text-300); margin-top: 4px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.about-split { grid-template-columns: 1fr; gap: 8px; }
|
||||
.about-split__visual { order: -1; }
|
||||
}
|
||||
|
||||
/* ─── Sections ───────────────────────────────────────────── */
|
||||
.values-section { background: var(--maroon-900); }
|
||||
.values-section .section__header h2 { color: rgba(255,255,255,.95); }
|
||||
.values-section .section__header p { color: rgba(255,255,255,.45); }
|
||||
.values-section .eyebrow { color: var(--amber); }
|
||||
|
||||
.cta-section {
|
||||
background: linear-gradient(135deg, var(--maroon-900) 0%, var(--maroon-800) 50%, #3A2430 100%);
|
||||
text-align: center; position: relative; overflow: hidden;
|
||||
}
|
||||
.cta-section::before {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background: radial-gradient(ellipse 80% 80% at 50% 50%, rgba(240,180,41,.06) 0%, transparent 65%);
|
||||
}
|
||||
.cta-section__content { position: relative; z-index: 1; max-width: 600px; margin-inline: auto; }
|
||||
.cta-section__title { color: #fff; margin-bottom: 16px; }
|
||||
.cta-section__desc { color: rgba(255,255,255,.68); font-size: 1.05rem; margin-bottom: 40px; }
|
||||
.cta-section__actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
|
||||
|
||||
/* ─── Footer ─────────────────────────────────────────────── */
|
||||
.footer { background: var(--maroon-950); color: rgba(255,255,255,.6); padding-top: 64px; }
|
||||
|
||||
.footer__grid {
|
||||
display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 48px;
|
||||
padding-bottom: 48px; border-bottom: 1px solid rgba(255,255,255,.07);
|
||||
}
|
||||
.footer__brand-logo {
|
||||
font-family: var(--font-body); font-size: 1.5rem; font-weight: 800;
|
||||
color: #fff; margin-bottom: 12px; display: block; letter-spacing: -.01em;
|
||||
}
|
||||
.footer__brand-desc { font-size: .875rem; line-height: 1.75; max-width: 280px; }
|
||||
.footer__heading {
|
||||
font-size: .68rem; font-weight: 800; letter-spacing: .14em;
|
||||
text-transform: uppercase; color: rgba(255,255,255,.35); margin-bottom: 16px;
|
||||
}
|
||||
.footer__links { display: flex; flex-direction: column; gap: 8px; }
|
||||
.footer__link { font-size: .875rem; color: rgba(255,255,255,.55); transition: color .2s; }
|
||||
.footer__link:hover { color: #fff; }
|
||||
.footer__bottom {
|
||||
padding: 20px 0; display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: .8rem; color: rgba(255,255,255,.3);
|
||||
}
|
||||
.footer__socials { display: flex; gap: 10px; }
|
||||
.footer__social {
|
||||
width: 34px; height: 34px; border-radius: var(--r-full);
|
||||
background: rgba(255,255,255,.07);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: .8rem; color: rgba(255,255,255,.55); transition: background .2s, color .2s;
|
||||
}
|
||||
.footer__social:hover { background: var(--maroon-600); color: #fff; }
|
||||
|
||||
@media (max-width: 1024px) { .footer__grid { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 640px) {
|
||||
.footer__grid { grid-template-columns: 1fr; gap: 32px; }
|
||||
.footer__bottom { flex-direction: column; gap: 10px; text-align: center; }
|
||||
}
|
||||
|
||||
/* ─── Module Inner Page ──────────────────────────────────── */
|
||||
.breadcrumb {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: .78rem; color: rgba(255,255,255,.45); margin-bottom: 16px;
|
||||
}
|
||||
.breadcrumb a { color: rgba(255,255,255,.45); transition: color .2s; }
|
||||
.breadcrumb a:hover { color: rgba(255,255,255,.85); }
|
||||
|
||||
/* ─── Contact ────────────────────────────────────────────── */
|
||||
.contact-grid { display: grid; grid-template-columns: 1fr 1.6fr; gap: 48px; align-items: start; }
|
||||
.contact-info__item { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 24px; }
|
||||
.contact-info__icon {
|
||||
width: 44px; height: 44px; flex-shrink: 0; border-radius: var(--r-md);
|
||||
background: var(--maroon-50);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.2rem;
|
||||
}
|
||||
.contact-info__label { font-size: .72rem; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; color: var(--text-300); margin-bottom: 2px; }
|
||||
.contact-info__value { font-size: .95rem; color: var(--text-700); }
|
||||
|
||||
.contact-form { background: #fff; padding: 36px; border-radius: var(--r-xl); border: 1px solid var(--earth-100); box-shadow: var(--sh-md); }
|
||||
.form-group { margin-bottom: 20px; }
|
||||
.form-label { display: block; font-size: .875rem; font-weight: 700; color: var(--text-700); margin-bottom: 6px; }
|
||||
.form-input, .form-select, .form-textarea {
|
||||
width: 100%; padding: 12px 16px; border: 1.5px solid var(--earth-200);
|
||||
border-radius: var(--r-md); font-family: inherit; font-size: .9rem;
|
||||
color: var(--text-700); background: var(--warm-white);
|
||||
transition: border-color .2s, box-shadow .2s; outline: none;
|
||||
}
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||
border-color: var(--maroon-400); box-shadow: 0 0 0 3px rgba(92,26,26,.1);
|
||||
}
|
||||
.form-textarea { resize: vertical; min-height: 120px; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.contact-grid { grid-template-columns: 1fr; }
|
||||
.contact-form { padding: 24px; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ─── Space Booking ──────────────────────────────────────── */
|
||||
.spaces-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.space-card {
|
||||
background: #fff;
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--earth-100);
|
||||
box-shadow: var(--sh-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform .3s var(--ease), box-shadow .3s var(--ease);
|
||||
}
|
||||
.space-card:hover { transform: translateY(-4px); box-shadow: var(--sh-lg); }
|
||||
|
||||
.space-card__cover {
|
||||
height: 160px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.space-card__icon {
|
||||
font-size: 2.4rem;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
filter: drop-shadow(0 4px 10px rgba(0,0,0,.25));
|
||||
}
|
||||
|
||||
.space-card__cover-tag {
|
||||
position: absolute;
|
||||
bottom: 12px; right: 14px;
|
||||
font-size: .7rem; font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,.3);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--r-full);
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
}
|
||||
|
||||
.space-card__body { padding: 20px 20px 12px; flex: 1; }
|
||||
.space-card__name { font-size: 1.15rem; margin-bottom: 6px; }
|
||||
.space-card__desc { font-size: .85rem; color: var(--text-300); line-height: 1.6; margin-bottom: 14px; }
|
||||
|
||||
.space-card__meta {
|
||||
display: flex; gap: 14px; flex-wrap: wrap;
|
||||
font-size: .78rem; color: var(--text-300);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.space-card__meta span {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
|
||||
.space-card__facilities { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.space-facility-tag {
|
||||
font-size: .68rem; font-weight: 700;
|
||||
padding: 3px 9px; border-radius: var(--r-full);
|
||||
background: var(--maroon-50); color: var(--maroon-700);
|
||||
}
|
||||
.space-facility-tag--more {
|
||||
background: var(--earth-100); color: var(--earth-700);
|
||||
}
|
||||
|
||||
.space-card__footer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--earth-100);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.space-card__price { font-family: var(--font-heading); font-size: 1.2rem; font-weight: 700; color: var(--maroon-700); }
|
||||
.space-card__price-unit { font-size: .78rem; color: var(--text-300); }
|
||||
|
||||
/* ─── Modal ──────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
background: rgba(30,10,10,.55);
|
||||
backdrop-filter: blur(6px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--warm-white);
|
||||
border-radius: var(--r-xl);
|
||||
width: 100%; max-width: 580px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--sh-xl);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
padding: 24px 28px 0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
background: none; border: none;
|
||||
color: var(--text-300); padding: 4px;
|
||||
border-radius: var(--r-sm);
|
||||
transition: color .2s, background .2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal__close:hover { color: var(--text-900); background: var(--earth-100); }
|
||||
|
||||
.modal__steps {
|
||||
display: flex; gap: 0; padding: 20px 28px 0;
|
||||
}
|
||||
.modal__step {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
flex: 1;
|
||||
font-size: .78rem; font-weight: 600; color: var(--text-100);
|
||||
position: relative;
|
||||
}
|
||||
.modal__step::after {
|
||||
content: '';
|
||||
position: absolute; right: 0; top: 12px;
|
||||
width: calc(100% - 80px); height: 1px;
|
||||
background: var(--earth-200);
|
||||
}
|
||||
.modal__step:last-child::after { display: none; }
|
||||
|
||||
.modal__step--active { color: var(--maroon-700); }
|
||||
.modal__step--done { color: var(--sage); }
|
||||
|
||||
.modal__step-num {
|
||||
width: 24px; height: 24px; flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--earth-100);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: .72rem; font-weight: 800;
|
||||
}
|
||||
.modal__step--active .modal__step-num { background: var(--maroon-700); color: #fff; }
|
||||
.modal__step--done .modal__step-num { background: var(--sage); color: #fff; }
|
||||
|
||||
.modal__body { padding: 24px 28px 28px; }
|
||||
|
||||
/* Booking summary block */
|
||||
.booking-summary {
|
||||
background: var(--cream);
|
||||
border-radius: var(--r-md);
|
||||
padding: 16px 20px;
|
||||
margin-top: 16px;
|
||||
border: 1px solid var(--earth-200);
|
||||
}
|
||||
.booking-summary__row {
|
||||
display: flex; justify-content: space-between; gap: 12px;
|
||||
font-size: .85rem; padding: 6px 0;
|
||||
border-bottom: 1px solid var(--earth-100);
|
||||
color: var(--text-500);
|
||||
}
|
||||
.booking-summary__row:last-child { border-bottom: none; }
|
||||
.booking-summary__row span:first-child { color: var(--text-300); }
|
||||
.booking-summary__row--total {
|
||||
font-weight: 700; color: var(--maroon-700) !important;
|
||||
font-size: .95rem; padding-top: 10px; margin-top: 4px;
|
||||
}
|
||||
.booking-summary__row--total span { color: var(--maroon-700) !important; }
|
||||
|
||||
/* ─── Misc ───────────────────────────────────────────────── */
|
||||
.page-top-pad { padding-top: var(--nav-h); }
|
||||
|
||||
.team-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; }
|
||||
.team-card { text-align: center; padding: 24px 16px; background: #fff; border-radius: var(--r-lg); border: 1px solid var(--earth-100); }
|
||||
.team-card__avatar {
|
||||
width: 76px; height: 76px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--maroon-200), var(--amber-light));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.8rem; margin: 0 auto 12px;
|
||||
}
|
||||
.team-card__name { font-size: 1rem; margin-bottom: 4px; }
|
||||
.team-card__role { font-size: .8rem; color: var(--text-300); }
|
||||
|
||||
.badge { display: inline-flex; align-items: center; gap: 5px; padding: 5px 12px; border-radius: var(--r-full); font-size: .75rem; font-weight: 700; }
|
||||
.badge--maroon { background: var(--maroon-100); color: var(--maroon-800); }
|
||||
.badge--earth { background: var(--earth-100); color: var(--earth-800); }
|
||||
|
||||
html { scroll-behavior: smooth; scroll-padding-top: var(--nav-h); }
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-up { animation: fadeUp .65s var(--ease) both; }
|
||||
.delay-1 { animation-delay: .1s; }
|
||||
.delay-2 { animation-delay: .2s; }
|
||||
.delay-3 { animation-delay: .3s; }
|
||||
|
||||
/* ─── Motion safety net ──────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('admin_token');
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders(),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || 'Request gagal');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface ContentBlock {
|
||||
id: number;
|
||||
page: string;
|
||||
section: string;
|
||||
key: string;
|
||||
value: string | null;
|
||||
content_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MediaFile {
|
||||
id: number;
|
||||
filename: string;
|
||||
original_name: string;
|
||||
file_url: string;
|
||||
file_type: string | null;
|
||||
purpose: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async login(username: string, password: string): Promise<string> {
|
||||
const data = await request<{ access_token: string }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
localStorage.setItem('admin_token', data.access_token);
|
||||
return data.access_token;
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('admin_token');
|
||||
},
|
||||
|
||||
isLoggedIn(): boolean {
|
||||
return !!getToken();
|
||||
},
|
||||
|
||||
async getContent(page?: string): Promise<ContentBlock[]> {
|
||||
const qs = page ? `?page=${page}` : '';
|
||||
return request<ContentBlock[]>(`/api/content/${qs}`);
|
||||
},
|
||||
|
||||
async upsertContent(data: Omit<ContentBlock, 'id' | 'created_at' | 'updated_at'>): Promise<ContentBlock> {
|
||||
return request<ContentBlock>('/api/content/', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async updateContent(id: number, value: string): Promise<ContentBlock> {
|
||||
return request<ContentBlock>(`/api/content/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ value }),
|
||||
});
|
||||
},
|
||||
|
||||
async getMedia(): Promise<MediaFile[]> {
|
||||
return request<MediaFile[]>('/api/media/');
|
||||
},
|
||||
|
||||
async uploadMedia(file: File, purpose?: string): Promise<MediaFile> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (purpose) formData.append('purpose', purpose);
|
||||
const res = await fetch(`${BASE_URL}/api/media/upload`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || 'Upload gagal');
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async deleteMedia(id: number): Promise<void> {
|
||||
await request(`/api/media/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
mediaUrl(path: string): string {
|
||||
if (path.startsWith('http')) return path;
|
||||
return `${BASE_URL}${path}`;
|
||||
},
|
||||
|
||||
async getUsers(): Promise<AdminUser[]> {
|
||||
return request<AdminUser[]>('/api/users/');
|
||||
},
|
||||
|
||||
async createUser(username: string, password: string): Promise<AdminUser> {
|
||||
return request<AdminUser>('/api/users/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteUser(id: number): Promise<void> {
|
||||
await request(`/api/users/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async toggleUser(id: number): Promise<AdminUser> {
|
||||
return request<AdminUser>(`/api/users/${id}/toggle`, { method: 'PATCH' });
|
||||
},
|
||||
|
||||
async changePassword(current_password: string, new_password: string): Promise<void> {
|
||||
await request('/api/users/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ current_password, new_password }),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, Check } from 'lucide-react';
|
||||
|
||||
const jalanList = [
|
||||
{
|
||||
icon: '🌾',
|
||||
title: 'Jalan Komunitas',
|
||||
color: 'var(--forest-600)',
|
||||
desc: 'Untuk siapa pun yang ingin hadir, belajar, dan tumbuh bersama — petani, pemelajar, praktisi pangan, pegiat sandang berkelanjutan.',
|
||||
points: ['Ngobrol santai', 'Belajar bersama', 'Workshop', 'Praktik lapangan', 'Partisipasi Pasar Bahagia', 'Riset'],
|
||||
cta: 'Daftarkan Diri Anda',
|
||||
},
|
||||
{
|
||||
icon: '🤝',
|
||||
title: 'Jalan Kemitraan',
|
||||
color: 'var(--maroon-600)',
|
||||
desc: 'Untuk organisasi, lembaga, dan individu yang ingin mendukung ekosistem secara struktural.',
|
||||
points: ['Kemitraan Program', 'Kemitraan Pendanaan', 'Kemitraan Kelembagaan'],
|
||||
cta: 'Mulai Perbincangan Kemitraan',
|
||||
},
|
||||
{
|
||||
icon: '✨',
|
||||
title: 'Jalan Berkarya',
|
||||
color: 'var(--earth-700)',
|
||||
desc: 'Untuk individu, kelompok, atau organisasi dengan gagasan/inisiatif selaras misi Anindhaloka.',
|
||||
points: ['Co-program', 'Co-event', 'Inisiatif bersama komunitas lokal'],
|
||||
cta: 'Ceritakan Gagasan Anda',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Bergabung() {
|
||||
return (
|
||||
<div className="page-top-pad">
|
||||
{/* Hero */}
|
||||
<section
|
||||
style={{
|
||||
background: 'linear-gradient(150deg, var(--forest-950) 0%, var(--forest-900) 60%, #1E3A2A 100%)',
|
||||
padding: '80px 0 96px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 60% 80% at 60% 50%, rgba(74,124,89,.15) 0%, transparent 60%)' }} />
|
||||
<div className="container" style={{ position: 'relative', zIndex: 1, maxWidth: 720 }}>
|
||||
<p className="eyebrow eyebrow--light" style={{ marginBottom: 16 }}>Bergabung & Berkolaborasi</p>
|
||||
<h1 style={{ color: '#fff', marginBottom: 20 }}>Rumah gotong royong,<br /><em style={{ fontStyle: 'italic', color: 'var(--earth-300)' }}>terbuka bagi siapa saja</em></h1>
|
||||
<p style={{ fontSize: '1.15rem', color: 'rgba(255,255,255,.7)', lineHeight: 1.8 }}>
|
||||
Tidak ada satu cara tunggal untuk menjadi bagian — yang penting ada keselarasan nilai, niat, dan cara memandang masa depan.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Three paths */}
|
||||
<section className="section" style={{ background: 'var(--cream)' }}>
|
||||
<div className="container">
|
||||
<div className="grid-3">
|
||||
{jalanList.map((j, i) => (
|
||||
<div key={i} className="card" style={{ padding: '32px 28px', display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ fontSize: '2rem', marginBottom: 16 }}>{j.icon}</div>
|
||||
<h3 style={{ marginBottom: 10 }}>{j.title}</h3>
|
||||
<p style={{ fontSize: '.92rem', color: 'var(--text-500)', lineHeight: 1.7, marginBottom: 20 }}>{j.desc}</p>
|
||||
<ul style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 24, flex: 1 }}>
|
||||
{j.points.map((p, k) => (
|
||||
<li key={k} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', fontSize: '.85rem', color: 'var(--text-500)' }}>
|
||||
<Check size={14} style={{ color: j.color, flexShrink: 0, marginTop: 3 }} /> {p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
to="/kontak"
|
||||
className="btn"
|
||||
style={{ borderColor: j.color, color: j.color, justifyContent: 'center' }}
|
||||
>
|
||||
{j.cta} <ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Closing invitation */}
|
||||
<section className="section" style={{ background: 'var(--warm-white)' }}>
|
||||
<div className="container" style={{ maxWidth: 720, textAlign: 'center' }}>
|
||||
<span style={{ fontSize: '3rem', display: 'block', marginBottom: 24 }}>🌾</span>
|
||||
<blockquote style={{ fontFamily: 'var(--font-heading)', fontSize: 'clamp(1.3rem, 2.8vw, 1.8rem)', fontStyle: 'italic', color: 'var(--text-900)', lineHeight: 1.55, marginBottom: 24 }}>
|
||||
"Kami tidak mengundang Anda untuk mendukung sebuah organisasi. Kami mengundang Anda untuk ikut merawat sesuatu yang kita semua butuhkan — cara hidup yang lebih selaras dengan bumi, lebih jujur pada sesama, dan lebih sadar pada setiap pilihan yang kita buat sehari-hari."
|
||||
</blockquote>
|
||||
<p style={{ color: 'var(--text-500)', fontSize: '1rem', marginBottom: 32 }}>
|
||||
Dari mana pun Anda datang dan apa pun yang Anda bawa — selamat datang di rumah ini.
|
||||
</p>
|
||||
<Link to="/kontak" className="btn btn--primary btn--lg">
|
||||
Hubungi Kami <ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import ModulePage from '../components/ModulePage';
|
||||
import modules from '../data/modules';
|
||||
|
||||
const module = modules.find(m => m.id === 'bhumi-anindha')!;
|
||||
|
||||
export default function BhumiAnindha() {
|
||||
return <ModulePage module={module} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import ModulePage from '../components/ModulePage';
|
||||
import modules from '../data/modules';
|
||||
|
||||
const module = modules.find(m => m.id === 'citta-rahayu')!;
|
||||
|
||||
export default function CittaRahayu() {
|
||||
return <ModulePage module={module} />;
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, ArrowDown } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import modules from '../data/modules';
|
||||
import IDGWheel from '../components/IDGWheel';
|
||||
import EcosystemIcon from '../components/EcosystemIcons';
|
||||
import ArcDivider from '../components/ArcDivider';
|
||||
import Reveal from '../components/Reveal';
|
||||
import TiltCard from '../components/TiltCard';
|
||||
import CountUp from '../components/CountUp';
|
||||
|
||||
const EASE_ORGANIC = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
const values = [
|
||||
{ icon: '🧠', title: 'Kesadaran', desc: 'Setiap tindakan dilandasi kehadiran penuh dan niat yang jernih.' },
|
||||
{ icon: '🌿', title: 'Hubungan dengan Alam', desc: 'Menghormati dan memulihkan hubungan kita dengan dunia alami.' },
|
||||
{ icon: '🤲', title: 'Gotong Royong', desc: 'Kekuatan komunitas adalah fondasi dari setiap perubahan nyata.' },
|
||||
{ icon: '♻️', title: 'Keberlanjutan', desc: 'Memilih jalan yang merawat, bukan menguras, sumber daya bumi.' },
|
||||
{ icon: '🌱', title: 'Regeneratif', desc: 'Melampaui keberlanjutan—memulihkan dan menghidupkan kembali.' },
|
||||
{ icon: '⚖️', title: 'Keadilan Sosial', desc: 'Ekonomi dan ekologi yang adil bagi semua makhluk hidup.' },
|
||||
{ icon: '💚', title: 'Komunitas', desc: 'Bersama kita tumbuh lebih kuat, lebih bijak, lebih utuh.' },
|
||||
{ icon: '✨', title: 'Keindahan', desc: 'Merayakan keindahan dalam kesederhanaan dan keaslian.' },
|
||||
];
|
||||
|
||||
const jalurList = [
|
||||
{
|
||||
id: 'pangan',
|
||||
icon: '🌾',
|
||||
title: 'Pangan Berkesadaran',
|
||||
subtitle: 'Merawat Tanah, Menumbuhkan Kehidupan',
|
||||
color: '#4F6B3A',
|
||||
steps: ['Budidaya', 'Panen & Pasca Panen', 'Gastronomi & Kuliner', 'Destinasi & Pengalaman'],
|
||||
quote: 'Merawat tanah adalah merawat diri.',
|
||||
},
|
||||
{
|
||||
id: 'sandang',
|
||||
icon: '🧵',
|
||||
title: 'Sandang Berkesadaran',
|
||||
subtitle: 'Menghidupkan Serat, Menenun Masa Depan',
|
||||
color: '#D4552B',
|
||||
steps: ['Serat', 'Benang & Kain', 'Pewarnaan', 'Produk Jadi'],
|
||||
quote: 'Menenun masa depan adalah memilih dunia seperti apa yang ingin kita wariskan.',
|
||||
},
|
||||
{
|
||||
id: 'olah-diri',
|
||||
icon: '🧘',
|
||||
title: 'Olah-Diri Berkesadaran',
|
||||
subtitle: 'Merawat Diri, Merawat Dunia',
|
||||
color: '#93583A',
|
||||
steps: ['Badan Kasar', 'Badan Halus', 'Badan Penyebab'],
|
||||
quote: 'Manusia yang utuh adalah manusia yang mampu merawat dunia di sekitarnya.',
|
||||
},
|
||||
];
|
||||
|
||||
const FEATURED_IDX = [0, 4];
|
||||
|
||||
const heroContainer = {
|
||||
hidden: {},
|
||||
show: { transition: { staggerChildren: 0.14, delayChildren: 0.1 } },
|
||||
};
|
||||
const heroItem = {
|
||||
hidden: { opacity: 0, y: 26 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.75, ease: EASE_ORGANIC } },
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="hero">
|
||||
<div className="hero__decor" />
|
||||
<motion.div
|
||||
className="container hero__content"
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
variants={heroContainer}
|
||||
>
|
||||
<motion.p className="eyebrow eyebrow--amber" style={{ marginBottom: 20 }} variants={heroItem}>
|
||||
Rumah Gotong Royong
|
||||
</motion.p>
|
||||
<motion.h1 className="hero__title" variants={heroItem}>
|
||||
Anindha. Loka.<br />
|
||||
<em>Dua kata, satu niat.</em>
|
||||
</motion.h1>
|
||||
<motion.p className="hero__subtitle" variants={heroItem}>
|
||||
Ruang hidup gotong royong untuk menghadirkan kerahayuan dalam kehidupan sehari-hari — melalui pangan, sandang, dan olah diri yang berkesadaran.
|
||||
</motion.p>
|
||||
<motion.div className="hero__actions" variants={heroItem}>
|
||||
<a href="#ekosistem" className="btn btn--amber btn--lg">
|
||||
Jelajahi Ekosistem <ArrowDown size={16} />
|
||||
</a>
|
||||
<Link to="/tentang" className="btn btn--outline-light btn--lg">
|
||||
Kenali Anindhaloka
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<div className="hero__scroll">
|
||||
<div className="hero__scroll-line" />
|
||||
<span>Gulir</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Definisi clip-path lengkung halus (bezier) untuk foto About — objectBoundingBox supaya tetap responsif */}
|
||||
<svg width="0" height="0" style={{ position: 'absolute' }} aria-hidden="true">
|
||||
<defs>
|
||||
<clipPath id="about-wave-clip" clipPathUnits="objectBoundingBox">
|
||||
<path d="M0,0.032 C0.1667,0.072 0.3333,0 0.5,0.024 C0.6667,0.048 0.8333,0.008 1,0.036 L1,1 L0,1 Z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{/* About — foto sendiri yang bergelombang di atas, bukan divider warna terpisah */}
|
||||
<section
|
||||
className="section about-section"
|
||||
style={{
|
||||
position: 'relative',
|
||||
marginTop: -70,
|
||||
paddingTop: 150,
|
||||
backgroundImage: 'linear-gradient(180deg, rgba(58,36,48,.92) 0%, rgba(47,29,36,.88) 20%, rgba(30,18,22,.85) 55%, rgba(30,18,22,.9) 100%), url(/assets/images/Anindhaloka.jpg)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
clipPath: 'url(#about-wave-clip)',
|
||||
}}
|
||||
>
|
||||
<div className="container">
|
||||
<div className="about-split">
|
||||
<Reveal className="about-split__text">
|
||||
<p className="eyebrow eyebrow--amber" style={{ marginBottom: 16 }}>Tentang Anindhaloka</p>
|
||||
<h2 style={{ marginBottom: 20, color: '#fff' }}>Ruang yang mulia, dibangun dengan kesadaran</h2>
|
||||
<p style={{ color: 'rgba(255,255,255,.75)' }}>
|
||||
Anindhaloka adalah ruang yang mulia — bukan karena megah, tapi karena setiap sudutnya dibangun dengan kesadaran: bahwa cara kita hidup menentukan dunia yang kita wariskan. Lahir di jantung Jakarta, kami mengajak siapa saja untuk menjalani hidup dengan cara yang lebih selaras.
|
||||
</p>
|
||||
<p style={{ color: 'rgba(255,255,255,.75)' }}>
|
||||
Kami menghadirkan cara hidup yang menyatukan kembali manusia dengan alam, dengan sesama, dan dengan dirinya sendiri — di mana makanan tumbuh dari tanah yang dirawat, pakaian dibuat dengan tangan yang sadar, dan ketenangan batin menjadi hak setiap orang.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 28 }}>
|
||||
<Link to="/tentang" className="btn btn--outline-light">
|
||||
Kenali Kami <ArrowRight size={16} />
|
||||
</Link>
|
||||
<Link to="/bergabung" className="btn btn--amber">
|
||||
Bergabung
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.15} className="about-split__visual" style={{ justifyContent: 'center', alignItems: 'center' }}>
|
||||
<IDGWheel light />
|
||||
</Reveal>
|
||||
</div>
|
||||
<div className="about-stats">
|
||||
<div className="about-stat">
|
||||
<CountUp to={60} suffix="+" className="about-stat__num" />
|
||||
<span className="about-stat__label">Jenama Lokal Bergabung</span>
|
||||
</div>
|
||||
<div className="about-stat">
|
||||
<CountUp to={50} suffix="+" className="about-stat__num" />
|
||||
<span className="about-stat__label">Kegiatan Edukasi Terselenggara</span>
|
||||
</div>
|
||||
<div className="about-stat">
|
||||
<CountUp to={2000} suffix="+" className="about-stat__num" />
|
||||
<span className="about-stat__label">Kunjungan TemanNIN</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<ArcDivider fill="var(--cream)" />
|
||||
|
||||
{/* Tiga Jalan Bertumbuh Bersama */}
|
||||
<section className="section" style={{ background: 'var(--cream)' }}>
|
||||
<div className="container">
|
||||
<div className="section__header">
|
||||
<p className="eyebrow">Tiga Jalan Bertumbuh Bersama</p>
|
||||
<h2>Satu perjalanan, tiga laku berkesadaran</h2>
|
||||
<p>Pangan, sandang, dan olah diri — tiga jalur praktik yang saling menopang dalam kehidupan sehari-hari.</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 64 }}>
|
||||
{jalurList.map((j, i) => (
|
||||
<Reveal key={j.id} delay={i * 0.08}>
|
||||
<div
|
||||
id={j.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: i % 2 === 1 ? 'row-reverse' : 'row',
|
||||
alignItems: 'center',
|
||||
gap: 40,
|
||||
flexWrap: 'wrap',
|
||||
scrollMarginTop: 100,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: '0 0 auto', position: 'relative', width: 148, height: 148 }}>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: 'var(--font-mono)', fontSize: '5.5rem', fontWeight: 700, color: j.color, opacity: 0.13,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
0{i + 1}
|
||||
</span>
|
||||
<span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '3.2rem' }}>
|
||||
{j.icon}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: '1 1 380px', minWidth: 280, textAlign: i % 2 === 1 ? 'right' : 'left' }}>
|
||||
<p className="eyebrow" style={{ color: j.color, marginBottom: 8 }}>{j.subtitle}</p>
|
||||
<h3 style={{ marginBottom: 16 }}>{j.title}</h3>
|
||||
<div style={{
|
||||
display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 20,
|
||||
justifyContent: i % 2 === 1 ? 'flex-end' : 'flex-start',
|
||||
}}>
|
||||
{j.steps.map((s, k) => (
|
||||
<span key={k} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span
|
||||
className="badge"
|
||||
style={{ background: `${j.color}18`, color: j.color, fontFamily: 'var(--font-mono)', fontWeight: 600, fontSize: '.72rem' }}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
{k < j.steps.length - 1 && <ArrowRight size={12} style={{ color: 'var(--text-100)' }} />}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<blockquote
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)', fontSize: '1.2rem', fontStyle: 'italic',
|
||||
color: 'var(--text-700)', margin: 0,
|
||||
borderLeft: i % 2 === 1 ? 'none' : `3px solid ${j.color}`,
|
||||
borderRight: i % 2 === 1 ? `3px solid ${j.color}` : 'none',
|
||||
paddingLeft: i % 2 === 1 ? 0 : 16,
|
||||
paddingRight: i % 2 === 1 ? 16 : 0,
|
||||
}}
|
||||
>
|
||||
“{j.quote}”
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<ArcDivider fill="var(--warm-white)" />
|
||||
|
||||
{/* Ecosystem Modules */}
|
||||
<section className="section ecosystem-section" id="ekosistem">
|
||||
<div className="container">
|
||||
<div className="section__header">
|
||||
<p className="eyebrow">Ekosistem Kami</p>
|
||||
<h2>Delapan wahana, satu perjalanan</h2>
|
||||
<p>Setiap modul adalah dunia tersendiri, namun saling terjalin dalam satu visi: kehidupan yang lebih sadar, adil, dan indah.</p>
|
||||
</div>
|
||||
<div className="ecosystem-mosaic">
|
||||
{modules.map((m, i) => {
|
||||
const featured = FEATURED_IDX.includes(i);
|
||||
return (
|
||||
<Reveal
|
||||
key={m.id}
|
||||
delay={(i % 4) * 0.06}
|
||||
className={featured ? 'ecosystem-mosaic__item--featured' : undefined}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<TiltCard style={{ height: '100%' }}>
|
||||
<Link to={m.path} style={{ textDecoration: 'none', display: 'block', height: '100%' }}>
|
||||
<div className="card card--module" style={{ height: '100%' }}>
|
||||
|
||||
{/* ── Cover ── */}
|
||||
<div
|
||||
className={`card__cover${featured ? ' card__cover--tall' : ''}`}
|
||||
style={{ background: `linear-gradient(140deg, ${m.gradientFrom} 0%, ${m.gradientTo} 55%, ${m.color} 100%)` }}
|
||||
>
|
||||
<div className="card__cover-blob" style={{ width: 140, height: 140, top: -40, right: -30, background: m.color, opacity: .28 }} />
|
||||
<div className="card__cover-blob" style={{ width: 90, height: 90, bottom: -20, left: 16, background: 'rgba(255,255,255,.08)' }} />
|
||||
<div className="card__cover-blob" style={{ width: 60, height: 60, top: 20, left: 20, background: 'rgba(255,255,255,.06)' }} />
|
||||
|
||||
{m.logoImage ? (
|
||||
<div className="card__cover-logo-badge">
|
||||
<img src={m.logoImage} alt={m.name} style={{ height: featured ? 68 : 50 }} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="card__cover-emoji">
|
||||
<EcosystemIcon id={m.id} size={featured ? 60 : 46} withBackdrop={false} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="card__cover-tag">{m.tagline}</div>
|
||||
</div>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div className="card__body">
|
||||
<h3 className="card__title">{m.name}</h3>
|
||||
<p className="card__desc">{m.description}</p>
|
||||
</div>
|
||||
<div className="card__footer">
|
||||
<span className="card__link" style={{ color: m.color }}>
|
||||
Pelajari lebih <ArrowRight size={14} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Link>
|
||||
</TiltCard>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<ArcDivider fill="var(--maroon-900)" />
|
||||
|
||||
{/* Values */}
|
||||
<section className="section values-section">
|
||||
<div className="container">
|
||||
<div className="section__header">
|
||||
<p className="eyebrow eyebrow--light">Nilai-nilai Kami</p>
|
||||
<h2 style={{ color: 'rgba(255,255,255,.95)' }}>Yang menghidupkan setiap langkah</h2>
|
||||
<p>Nilai bukan sekadar kata-kata. Ini adalah cara kami berpikir, berkarya, dan berhubungan dengan dunia.</p>
|
||||
</div>
|
||||
<div className="grid-4">
|
||||
{values.map((v, i) => (
|
||||
<Reveal key={i} delay={(i % 4) * 0.06}>
|
||||
<div className="value-card">
|
||||
<div className="value-card__icon">{v.icon}</div>
|
||||
<h4 className="value-card__title">{v.title}</h4>
|
||||
<p className="value-card__desc">{v.desc}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<ArcDivider fill="var(--cream)" />
|
||||
|
||||
{/* Community Quote */}
|
||||
<section className="section" style={{ background: 'var(--cream)' }}>
|
||||
<Reveal className="container" style={{ maxWidth: 720, textAlign: 'center' }}>
|
||||
<span style={{ fontSize: '3rem', display: 'block', marginBottom: 24 }}>🌾</span>
|
||||
<blockquote style={{ fontFamily: 'var(--font-heading)', fontSize: 'clamp(1.4rem, 3vw, 2rem)', fontStyle: 'italic', color: 'var(--text-900)', lineHeight: 1.5, marginBottom: 24 }}>
|
||||
"Kita tidak mewarisi bumi dari nenek moyang kita, kita meminjamnya dari anak cucu kita."
|
||||
</blockquote>
|
||||
<p style={{ color: 'var(--text-300)', fontSize: '.9rem' }}>— Pepatah bijak yang menginspirasi gerakan kami</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
<ArcDivider fill="var(--maroon-900)" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className="section cta-section">
|
||||
<div className="container">
|
||||
<div className="cta-section__content">
|
||||
<p className="eyebrow eyebrow--light" style={{ marginBottom: 16 }}>Mulai Perjalananmu</p>
|
||||
<h2 className="cta-section__title">Jadilah bagian dari komunitas perubahan</h2>
|
||||
<p className="cta-section__desc">
|
||||
Bergabunglah dengan ratusan individu, pengrajin, dan pelaku perubahan yang bersama-sama membangun ekosistem hidup yang lebih bermakna.
|
||||
</p>
|
||||
<div className="cta-section__actions">
|
||||
<Link to="/bergabung" className="btn btn--amber btn--lg">
|
||||
Daftar Sekarang <ArrowRight size={16} />
|
||||
</Link>
|
||||
<Link to="/tentang" className="btn btn--outline-light btn--lg">
|
||||
Pelajari Lebih
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import ModulePage from '../components/ModulePage';
|
||||
import modules from '../data/modules';
|
||||
|
||||
const module = modules.find(m => m.id === 'kaind')!;
|
||||
|
||||
export default function KaIND() {
|
||||
return <ModulePage module={module} />;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useState } from 'react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
export default function Kontak() {
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSent(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-top-pad">
|
||||
{/* Header */}
|
||||
<section
|
||||
style={{
|
||||
background: 'linear-gradient(150deg, var(--forest-950) 0%, var(--forest-900) 60%, #1E3A2A 100%)',
|
||||
padding: '80px 0 96px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 50% 70% at 50% 50%, rgba(74,124,89,.15) 0%, transparent 60%)' }} />
|
||||
<div className="container" style={{ position: 'relative', zIndex: 1, maxWidth: 600, textAlign: 'center' }}>
|
||||
<p className="eyebrow eyebrow--light" style={{ marginBottom: 16 }}>Kontak Kami</p>
|
||||
<h1 style={{ color: '#fff', marginBottom: 16 }}>Mari berbicara</h1>
|
||||
<p style={{ fontSize: '1.1rem', color: 'rgba(255,255,255,.7)', lineHeight: 1.75 }}>
|
||||
Kami senang mendengar dari Anda. Apakah Anda ingin bergabung, berkolaborasi, atau sekadar ingin tahu lebih banyak—kami ada di sini.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Content */}
|
||||
<section className="section" style={{ background: 'var(--warm-white)' }}>
|
||||
<div className="container">
|
||||
<div className="contact-grid">
|
||||
{/* Info */}
|
||||
<div>
|
||||
<h3 style={{ marginBottom: 24 }}>Informasi Kontak</h3>
|
||||
|
||||
<div style={{
|
||||
background: 'var(--earth-50)', border: '1px solid var(--earth-100)',
|
||||
borderRadius: 'var(--r-md)', padding: '14px 16px', marginBottom: 24,
|
||||
fontSize: '.85rem', color: 'var(--text-500)', lineHeight: 1.6,
|
||||
}}>
|
||||
Kontak umum Anindhaloka (kantor pusat) akan segera tersedia. Untuk saat ini, hubungi tim sesuai inisiatif yang relevan lewat kontak di bawah, atau kirim pesan lewat form — tim kami akan meneruskannya.
|
||||
</div>
|
||||
|
||||
<div className="contact-info__item">
|
||||
<div className="contact-info__icon">📱</div>
|
||||
<div>
|
||||
<p className="contact-info__label">Pasar Bahagia & LokaNIN</p>
|
||||
<p className="contact-info__value">+62 815 7330 5808 (Welin Cakra)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="contact-info__item">
|
||||
<div className="contact-info__icon">📷</div>
|
||||
<div>
|
||||
<p className="contact-info__label">Instagram</p>
|
||||
<p className="contact-info__value">@anindhaloka_id · @kreasi.nin</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="contact-info__item">
|
||||
<div className="contact-info__icon">🌱</div>
|
||||
<div>
|
||||
<p className="contact-info__label">Bhumi Anindha</p>
|
||||
<p className="contact-info__value">IG @bhumi.anindha <span style={{ opacity: .6 }}>(kontak sedang diperbarui)</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 32 }}>
|
||||
<p style={{ fontSize: '.875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--text-300)', marginBottom: 12 }}>
|
||||
Ikuti Kami
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{['📷 Instagram', '📘 Facebook', '▶️ YouTube', '💬 WhatsApp'].map(s => (
|
||||
<a
|
||||
key={s}
|
||||
href="#"
|
||||
className="badge badge--forest"
|
||||
style={{ fontSize: '.775rem', padding: '6px 12px' }}
|
||||
>
|
||||
{s}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map placeholder */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 32,
|
||||
height: 200,
|
||||
borderRadius: 'var(--r-lg)',
|
||||
background: 'linear-gradient(135deg, var(--forest-100), var(--earth-100))',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: '1px solid var(--earth-100)',
|
||||
flexDirection: 'column', gap: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '2rem' }}>🗺️</span>
|
||||
<p style={{ fontSize: '.875rem', color: 'var(--text-300)' }}>Lokasi akan diinformasikan lebih lanjut</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="contact-form">
|
||||
{sent ? (
|
||||
<div style={{ textAlign: 'center', padding: '48px 0' }}>
|
||||
<div style={{ fontSize: '3rem', marginBottom: 16 }}>🌿</div>
|
||||
<h3 style={{ marginBottom: 12 }}>Pesan terkirim!</h3>
|
||||
<p style={{ color: 'var(--text-300)', lineHeight: 1.7 }}>
|
||||
Terima kasih telah menghubungi kami. Tim Anindhaloka akan merespons dalam 1–2 hari kerja. Sampai jumpa! 🙏
|
||||
</p>
|
||||
<button
|
||||
className="btn btn--outline"
|
||||
style={{ marginTop: 24 }}
|
||||
onClick={() => setSent(false)}
|
||||
>
|
||||
Kirim pesan lain
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h3 style={{ marginBottom: 24 }}>Kirim Pesan</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nama Lengkap *</label>
|
||||
<input className="form-input" type="text" placeholder="Nama Anda" required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email *</label>
|
||||
<input className="form-input" type="email" placeholder="email@anda.com" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nomor WhatsApp</label>
|
||||
<input className="form-input" type="tel" placeholder="+62 8xx xxxx xxxx" />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Topik *</label>
|
||||
<select className="form-select" required>
|
||||
<option value="">Pilih topik...</option>
|
||||
<option>Keanggotaan TemanNIN</option>
|
||||
<option>Bhumi Anindha & Pertanian</option>
|
||||
<option>PAON & Kuliner</option>
|
||||
<option>KaIND & Fashion</option>
|
||||
<option>Citta Rahayu & Wellness</option>
|
||||
<option>Pasar Bahagia & Vendor</option>
|
||||
<option>LearnNIN & Edukasi</option>
|
||||
<option>LokaNIN & Ruang</option>
|
||||
<option>Kolaborasi & Kemitraan</option>
|
||||
<option>Lainnya</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Pesan *</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
placeholder="Ceritakan kepada kami bagaimana kami bisa membantu Anda..."
|
||||
required
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn--primary btn--lg" style={{ width: '100%', justifyContent: 'center' }}>
|
||||
Kirim Pesan <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import ModulePage from '../components/ModulePage';
|
||||
import modules from '../data/modules';
|
||||
|
||||
const module = modules.find(m => m.id === 'learnnin')!;
|
||||
|
||||
export default function LearnNIN() {
|
||||
return <ModulePage module={module} />;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, Calendar, Users, Clock } from 'lucide-react';
|
||||
import ModulePage from '../components/ModulePage';
|
||||
import modules from '../data/modules';
|
||||
|
||||
const module = modules.find(m => m.id === 'lokanin')!;
|
||||
|
||||
export default function LokaNIN() {
|
||||
return (
|
||||
<>
|
||||
<ModulePage module={module} />
|
||||
|
||||
{/* Booking CTA */}
|
||||
<section className="section" style={{ background: 'var(--cream)' }}>
|
||||
<div className="container">
|
||||
<div style={{
|
||||
background: `linear-gradient(140deg, #2D1510 0%, #5C2A20 55%, ${module.color} 100%)`,
|
||||
borderRadius: 'var(--r-xl)',
|
||||
padding: '56px 48px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 32,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 50% 80% at 80% 50%, rgba(255,255,255,.05) 0%, transparent 60%)' }} />
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, maxWidth: 560 }}>
|
||||
<div style={{ display: 'flex', gap: 24, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ icon: <Calendar size={15} />, label: '5 pilihan ruang' },
|
||||
{ icon: <Users size={15} />, label: 'Kapasitas 8–100 orang' },
|
||||
{ icon: <Clock size={15} />, label: 'Booking 24 jam' },
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '.82rem', color: 'rgba(255,255,255,.7)' }}>
|
||||
{item.icon} {item.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<h2 style={{ color: '#fff', marginBottom: 12 }}>Pesan ruang untuk kegiatanmu</h2>
|
||||
<p style={{ color: 'rgba(255,255,255,.7)', fontSize: '1rem', lineHeight: 1.75 }}>
|
||||
Dari ruang diskusi kecil hingga aula besar—setiap ruang dirancang untuk mendukung kegiatan komunitas yang bermakna.
|
||||
</p>
|
||||
<p style={{ color: 'rgba(255,255,255,.55)', fontSize: '.85rem', marginTop: 12 }}>
|
||||
Tanya ketersediaan & detail terkini: +62 815 7330 5808 (Welin Cakra) · @anindhaloka_id · @kreasi.nin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Link to="/lokanin/pesan" className="btn btn--amber btn--lg">
|
||||
Lihat & Pesan Ruang <ArrowRight size={16} />
|
||||
</Link>
|
||||
<Link to="/kontak" className="btn btn--outline-light">
|
||||
Tanya Dulu
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import ModulePage from '../components/ModulePage';
|
||||
import modules from '../data/modules';
|
||||
|
||||
const module = modules.find(m => m.id === 'paon')!;
|
||||
|
||||
export default function PAON() {
|
||||
return <ModulePage module={module} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import ModulePage from '../components/ModulePage';
|
||||
import modules from '../data/modules';
|
||||
|
||||
const module = modules.find(m => m.id === 'pasar-bahagia')!;
|
||||
|
||||
export default function PasarBahagia() {
|
||||
return <ModulePage module={module} />;
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronRight, Users, Maximize2, Clock, Check, ArrowRight, X } from 'lucide-react';
|
||||
|
||||
/* ─── Data ────────────────────────────────────────────────── */
|
||||
const spaces = [
|
||||
{
|
||||
id: 'ruang-komunitas',
|
||||
name: 'Ruang Komunitas',
|
||||
desc: 'Ruang serbaguna yang hangat untuk pertemuan, diskusi kelompok, dan kegiatan komunitas.',
|
||||
longDesc: 'Ruang Komunitas dirancang dengan suasana hangat dan inklusif. Tersedia kursi yang dapat disusun ulang sesuai kebutuhan—dari formasi lingkaran untuk diskusi, deretan untuk presentasi, hingga konfigurasi bebas untuk workshop kreatif.',
|
||||
capacity: 30,
|
||||
area: '45 m²',
|
||||
price: 150_000,
|
||||
priceUnit: 'jam',
|
||||
minHour: 2,
|
||||
icon: '🏛️',
|
||||
facilities: ['Proyektor & layar', 'Whiteboard', 'AC', 'WiFi cepat', 'Sound system', 'Meja & kursi fleksibel'],
|
||||
available: true,
|
||||
color: '#9E5A4A',
|
||||
},
|
||||
{
|
||||
id: 'studio-kreatif',
|
||||
name: 'Studio Kreatif',
|
||||
desc: 'Workshop space dengan peralatan lengkap untuk pelatihan, eksplorasi, dan sesi kreatif.',
|
||||
longDesc: 'Studio Kreatif adalah ruang yang mendukung berbagai jenis kegiatan berbasis tangan—kerajinan, memasak, seni, hingga workshop kesehatan. Dilengkapi dengan sink, meja kerja lebar, dan pencahayaan natural yang memadai.',
|
||||
capacity: 15,
|
||||
area: '30 m²',
|
||||
price: 200_000,
|
||||
priceUnit: 'jam',
|
||||
minHour: 3,
|
||||
icon: '🎨',
|
||||
facilities: ['Meja kerja lebar', 'Sink & air mengalir', 'Rak penyimpanan', 'AC', 'WiFi', 'Pencahayaan natural'],
|
||||
available: true,
|
||||
color: '#9E5A4A',
|
||||
},
|
||||
{
|
||||
id: 'aula-utama',
|
||||
name: 'Aula Utama',
|
||||
desc: 'Ruang besar untuk event, pameran, pertunjukan, dan pertemuan komunitas skala luas.',
|
||||
longDesc: 'Aula Utama adalah ruang terbesar di LokaNIN—cocok untuk bazaar kecil, pertunjukan seni, konferensi komunitas, atau pameran. Dengan langit-langit tinggi dan tata suara profesional, setiap acara akan terasa berkesan.',
|
||||
capacity: 100,
|
||||
area: '150 m²',
|
||||
price: 500_000,
|
||||
priceUnit: 'jam',
|
||||
minHour: 4,
|
||||
icon: '🎪',
|
||||
facilities: ['Panggung kecil', 'Proyektor besar', 'Sound system profesional', 'AC', 'WiFi', 'Dapur mini', 'Toilet terpisah'],
|
||||
available: true,
|
||||
color: '#9E5A4A',
|
||||
},
|
||||
{
|
||||
id: 'ruang-diskusi',
|
||||
name: 'Ruang Diskusi',
|
||||
desc: 'Ruang intim untuk rapat tim kecil, sesi mentoring, konsultasi, atau sesi fokus.',
|
||||
longDesc: 'Ruang Diskusi memberikan privasi yang cukup untuk percakapan yang bermakna. Dengan kapasitas 8 orang, ruang ini ideal untuk pertemuan tim kecil, sesi 1-on-1, hingga sesi coaching dan konsultasi.',
|
||||
capacity: 8,
|
||||
area: '16 m²',
|
||||
price: 75_000,
|
||||
priceUnit: 'jam',
|
||||
minHour: 1,
|
||||
icon: '💬',
|
||||
facilities: ['TV/Monitor 55"', 'Whiteboard', 'AC', 'WiFi', 'Meja bulat & kursi'],
|
||||
available: true,
|
||||
color: '#9E5A4A',
|
||||
},
|
||||
{
|
||||
id: 'coworking',
|
||||
name: 'Co-working Space',
|
||||
desc: 'Area kerja bersama yang inspiratif untuk bekerja mandiri, berjejaring, dan berkolaborasi.',
|
||||
longDesc: 'Co-working Space LokaNIN lebih dari sekadar tempat kerja—ini adalah ruang temu para pelaku perubahan. Dengan suasana yang mendorong kolaborasi, siapa saja bisa datang, bekerja, dan pulang dengan koneksi baru.',
|
||||
capacity: 25,
|
||||
area: '60 m²',
|
||||
price: 50_000,
|
||||
priceUnit: 'kursi/hari',
|
||||
minHour: 1,
|
||||
icon: '💻',
|
||||
facilities: ['Kursi ergonomis', 'Loker pribadi', 'Pantry & kopi', 'WiFi 100 Mbps', 'Printer', 'Ruang telepon'],
|
||||
available: true,
|
||||
color: '#9E5A4A',
|
||||
},
|
||||
];
|
||||
|
||||
const timeSlots = [
|
||||
'08:00', '09:00', '10:00', '11:00', '12:00',
|
||||
'13:00', '14:00', '15:00', '16:00', '17:00', '18:00',
|
||||
];
|
||||
|
||||
function fmt(n: number) {
|
||||
return 'Rp ' + n.toLocaleString('id-ID');
|
||||
}
|
||||
|
||||
/* ─── Space Card ──────────────────────────────────────────── */
|
||||
function SpaceCard({ space, onSelect }: { space: typeof spaces[0]; onSelect: () => void }) {
|
||||
return (
|
||||
<div className="space-card">
|
||||
{/* Cover */}
|
||||
<div className="space-card__cover" style={{
|
||||
background: `linear-gradient(145deg, #2D1510 0%, #5C2A20 50%, ${space.color} 100%)`,
|
||||
}}>
|
||||
<span className="space-card__icon">{space.icon}</span>
|
||||
<div className="space-card__cover-tag">
|
||||
{space.available ? '✓ Tersedia' : '✗ Tidak tersedia'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-card__body">
|
||||
<h3 className="space-card__name">{space.name}</h3>
|
||||
<p className="space-card__desc">{space.desc}</p>
|
||||
|
||||
<div className="space-card__meta">
|
||||
<span><Users size={13} /> {space.capacity} orang</span>
|
||||
<span><Maximize2 size={13} /> {space.area}</span>
|
||||
<span><Clock size={13} /> Min. {space.minHour} jam</span>
|
||||
</div>
|
||||
|
||||
<div className="space-card__facilities">
|
||||
{space.facilities.slice(0, 3).map(f => (
|
||||
<span key={f} className="space-facility-tag">{f}</span>
|
||||
))}
|
||||
{space.facilities.length > 3 && (
|
||||
<span className="space-facility-tag space-facility-tag--more">
|
||||
+{space.facilities.length - 3} lainnya
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="space-card__footer">
|
||||
<div>
|
||||
<span className="space-card__price">{fmt(space.price)}</span>
|
||||
<span className="space-card__price-unit"> / {space.priceUnit}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn--primary btn--sm"
|
||||
onClick={onSelect}
|
||||
disabled={!space.available}
|
||||
>
|
||||
Pesan <ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Booking Modal ───────────────────────────────────────── */
|
||||
function BookingModal({ space, onClose }: { space: typeof spaces[0]; onClose: () => void }) {
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [form, setForm] = useState({
|
||||
date: '', startTime: '09:00', duration: space.minHour,
|
||||
name: '', phone: '', email: '', purpose: '', notes: '',
|
||||
});
|
||||
|
||||
const total = space.priceUnit === 'kursi/hari'
|
||||
? space.price
|
||||
: space.price * form.duration;
|
||||
|
||||
function set(k: keyof typeof form, v: string | number) {
|
||||
setForm(prev => ({ ...prev, [k]: v }));
|
||||
}
|
||||
|
||||
const step1Valid = form.date && form.startTime;
|
||||
const step2Valid = form.name && form.phone && form.email && form.purpose;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
{/* Header */}
|
||||
<div className="modal__header">
|
||||
<div>
|
||||
<p style={{ fontSize: '.75rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.1em', color: 'var(--maroon-500)', marginBottom: 4 }}>
|
||||
Pemesanan Ruang
|
||||
</p>
|
||||
<h3 style={{ margin: 0 }}>{space.icon} {space.name}</h3>
|
||||
</div>
|
||||
<button className="modal__close" onClick={onClose}><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="modal__steps">
|
||||
{['Jadwal', 'Data Diri', 'Konfirmasi'].map((s, i) => (
|
||||
<div key={i} className={`modal__step ${step > i + 1 ? 'modal__step--done' : ''} ${step === i + 1 ? 'modal__step--active' : ''}`}>
|
||||
<div className="modal__step-num">{step > i + 1 ? <Check size={12} /> : i + 1}</div>
|
||||
<span>{s}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1 – Jadwal */}
|
||||
{step === 1 && (
|
||||
<div className="modal__body">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Tanggal *</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="date"
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
value={form.date}
|
||||
onChange={e => set('date', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Jam Mulai *</label>
|
||||
<select className="form-select" value={form.startTime} onChange={e => set('startTime', e.target.value)}>
|
||||
{timeSlots.map(t => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{space.priceUnit === 'jam' && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">Durasi (jam) — minimum {space.minHour} jam</label>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 4 }}>
|
||||
{[space.minHour, space.minHour + 1, space.minHour + 2, space.minHour + 3, 8].filter((v, i, a) => a.indexOf(v) === i && v <= 8).map(h => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
className={`btn btn--sm ${form.duration === h ? 'btn--primary' : 'btn--outline'}`}
|
||||
onClick={() => set('duration', h)}
|
||||
>
|
||||
{h} jam
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="booking-summary">
|
||||
<div className="booking-summary__row">
|
||||
<span>Ruang</span><span>{space.name}</span>
|
||||
</div>
|
||||
{form.date && (
|
||||
<div className="booking-summary__row">
|
||||
<span>Tanggal</span>
|
||||
<span>{new Date(form.date).toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{form.startTime && (
|
||||
<div className="booking-summary__row">
|
||||
<span>Jam</span>
|
||||
<span>{form.startTime}{space.priceUnit === 'jam' ? ` – ${String(parseInt(form.startTime) + form.duration).padStart(2,'0')}:00` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="booking-summary__row booking-summary__row--total">
|
||||
<span>Total</span>
|
||||
<span>{fmt(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn--primary btn--lg"
|
||||
style={{ width: '100%', justifyContent: 'center', marginTop: 8 }}
|
||||
disabled={!step1Valid}
|
||||
onClick={() => setStep(2)}
|
||||
>
|
||||
Lanjut ke Data Diri <ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2 – Data Diri */}
|
||||
{step === 2 && (
|
||||
<div className="modal__body">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nama Lengkap *</label>
|
||||
<input className="form-input" type="text" placeholder="Nama Anda" value={form.name} onChange={e => set('name', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">WhatsApp *</label>
|
||||
<input className="form-input" type="tel" placeholder="+62 8xx xxxx xxxx" value={form.phone} onChange={e => set('phone', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email *</label>
|
||||
<input className="form-input" type="email" placeholder="email@anda.com" value={form.email} onChange={e => set('email', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Tujuan Penggunaan *</label>
|
||||
<select className="form-select" value={form.purpose} onChange={e => set('purpose', e.target.value)}>
|
||||
<option value="">Pilih tujuan...</option>
|
||||
<option>Workshop / Pelatihan</option>
|
||||
<option>Pertemuan Komunitas</option>
|
||||
<option>Rapat Tim</option>
|
||||
<option>Event / Pameran</option>
|
||||
<option>Sesi Mentoring / Konsultasi</option>
|
||||
<option>Produksi Konten</option>
|
||||
<option>Lainnya</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Catatan Tambahan</label>
|
||||
<textarea className="form-textarea" rows={3} placeholder="Kebutuhan khusus, setup ruangan, dll." value={form.notes} onChange={e => set('notes', e.target.value)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button className="btn btn--outline" onClick={() => setStep(1)}>← Kembali</button>
|
||||
<button
|
||||
className="btn btn--primary btn--lg"
|
||||
style={{ flex: 1, justifyContent: 'center' }}
|
||||
disabled={!step2Valid}
|
||||
onClick={() => setStep(3)}
|
||||
>
|
||||
Tinjau Pesanan <ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3 – Konfirmasi */}
|
||||
{step === 3 && (
|
||||
<div className="modal__body">
|
||||
<div className="booking-summary">
|
||||
<h4 style={{ marginBottom: 12, fontFamily: 'var(--font-heading)', fontSize: '1.1rem' }}>Ringkasan Pemesanan</h4>
|
||||
{[
|
||||
['Ruang', space.name],
|
||||
['Tanggal', new Date(form.date).toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })],
|
||||
['Jam', `${form.startTime}${space.priceUnit === 'jam' ? ` – ${String(parseInt(form.startTime) + form.duration).padStart(2,'0')}:00 (${form.duration} jam)` : ''}`],
|
||||
['Nama', form.name],
|
||||
['WhatsApp', form.phone],
|
||||
['Email', form.email],
|
||||
['Tujuan', form.purpose],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="booking-summary__row">
|
||||
<span>{k}</span><span>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
{form.notes && (
|
||||
<div className="booking-summary__row">
|
||||
<span>Catatan</span><span>{form.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="booking-summary__row booking-summary__row--total">
|
||||
<span>Total Pembayaran</span>
|
||||
<span>{fmt(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '.82rem', color: 'var(--text-300)', marginTop: 12, marginBottom: 20, lineHeight: 1.65 }}>
|
||||
Dengan menekan "Konfirmasi Pemesanan", tim kami akan menghubungi Anda melalui WhatsApp untuk proses pembayaran dan konfirmasi akhir.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button className="btn btn--outline" onClick={() => setStep(2)}>← Kembali</button>
|
||||
<button
|
||||
className="btn btn--primary btn--lg"
|
||||
style={{ flex: 1, justifyContent: 'center' }}
|
||||
onClick={() => setStep('done' as unknown as 3)}
|
||||
>
|
||||
Konfirmasi Pemesanan ✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Done */}
|
||||
{(step as unknown as string) === 'done' && (
|
||||
<div className="modal__body" style={{ textAlign: 'center', padding: '40px 32px' }}>
|
||||
<div style={{ fontSize: '3.5rem', marginBottom: 16 }}>🎉</div>
|
||||
<h3 style={{ marginBottom: 12 }}>Pesanan Diterima!</h3>
|
||||
<p style={{ color: 'var(--text-300)', lineHeight: 1.75, marginBottom: 24 }}>
|
||||
Terima kasih, <strong>{form.name}</strong>! Permintaan pemesanan <strong>{space.name}</strong> pada{' '}
|
||||
{new Date(form.date).toLocaleDateString('id-ID', { day: 'numeric', month: 'long' })} telah kami terima.
|
||||
<br /><br />
|
||||
Tim LokaNIN akan menghubungi Anda di <strong>{form.phone}</strong> dalam 1×24 jam untuk konfirmasi dan pembayaran.
|
||||
</p>
|
||||
<button className="btn btn--primary" onClick={onClose}>
|
||||
Selesai
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Page ───────────────────────────────────────────── */
|
||||
export default function PesanRuang() {
|
||||
const [selected, setSelected] = useState<typeof spaces[0] | null>(null);
|
||||
|
||||
return (
|
||||
<div className="page-top-pad">
|
||||
{/* Hero */}
|
||||
<section style={{
|
||||
background: 'linear-gradient(150deg, #2D1510 0%, #5C2A20 55%, #9E5A4A 100%)',
|
||||
padding: '80px 0 96px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 60% 80% at 60% 50%, rgba(158,90,74,.2) 0%, transparent 60%)' }} />
|
||||
<div className="container" style={{ position: 'relative', zIndex: 1 }}>
|
||||
<nav className="breadcrumb">
|
||||
<Link to="/">Beranda</Link>
|
||||
<ChevronRight size={12} />
|
||||
<Link to="/lokanin">LokaNIN</Link>
|
||||
<ChevronRight size={12} />
|
||||
<span style={{ color: 'rgba(255,255,255,.8)' }}>Pesan Ruang</span>
|
||||
</nav>
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<p className="eyebrow eyebrow--amber" style={{ marginBottom: 16 }}>🏡 LokaNIN</p>
|
||||
<h1 style={{ color: '#fff', marginBottom: 20 }}>Pesan Ruang</h1>
|
||||
<p style={{ fontSize: '1.1rem', color: 'rgba(255,255,255,.7)', lineHeight: 1.8, marginBottom: 12 }}>
|
||||
Temukan ruang yang sesuai dengan kebutuhanmu. Dari ruang diskusi kecil hingga aula besar—semua tersedia untuk komunitas.
|
||||
</p>
|
||||
<p style={{ fontSize: '.85rem', color: 'rgba(255,255,255,.5)', marginBottom: 32 }}>
|
||||
🌱 Ruang yang sedang tumbuh — nama ruang, foto, dan harga di bawah ini adalah contoh ilustratif. Hubungi kami untuk info dan ketersediaan terkini.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'rgba(255,255,255,.7)', fontSize: '.875rem' }}>
|
||||
<Users size={16} /> {spaces.length} pilihan ruang
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'rgba(255,255,255,.7)', fontSize: '.875rem' }}>
|
||||
<Clock size={16} /> Booking online 24 jam
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'rgba(255,255,255,.7)', fontSize: '.875rem' }}>
|
||||
<Check size={16} /> Konfirmasi via WhatsApp
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section className="section--sm" style={{ background: 'var(--cream)' }}>
|
||||
<div className="container">
|
||||
<div style={{ display: 'flex', gap: 0, overflowX: 'auto' }}>
|
||||
{[
|
||||
{ n: '01', icon: '🔍', title: 'Pilih Ruang', desc: 'Cari dan pilih ruang yang sesuai dengan kebutuhan dan kapasitas.' },
|
||||
{ n: '02', icon: '📅', title: 'Pilih Jadwal', desc: 'Tentukan tanggal, jam mulai, dan durasi pemakaian.' },
|
||||
{ n: '03', icon: '📝', title: 'Isi Data', desc: 'Lengkapi informasi diri dan tujuan penggunaan.' },
|
||||
{ n: '04', icon: '✅', title: 'Konfirmasi', desc: 'Tim kami hubungi via WhatsApp untuk pembayaran & konfirmasi.' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ flex: '1 1 200px', padding: '24px 20px', position: 'relative' }}>
|
||||
{i < 3 && (
|
||||
<div style={{ position: 'absolute', top: 36, right: -12, color: 'var(--earth-300)', fontSize: '1.2rem', zIndex: 1 }}>→</div>
|
||||
)}
|
||||
<div style={{ fontSize: '1.6rem', marginBottom: 8 }}>{s.icon}</div>
|
||||
<div style={{ fontSize: '.68rem', fontWeight: 800, letterSpacing: '.1em', textTransform: 'uppercase', color: 'var(--maroon-500)', marginBottom: 6 }}>
|
||||
{s.n}
|
||||
</div>
|
||||
<h4 style={{ marginBottom: 6 }}>{s.title}</h4>
|
||||
<p style={{ fontSize: '.82rem', color: 'var(--text-300)', lineHeight: 1.6 }}>{s.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Spaces grid */}
|
||||
<section className="section" style={{ background: 'var(--warm-white)' }}>
|
||||
<div className="container">
|
||||
<div className="section__header">
|
||||
<p className="eyebrow">Pilihan Ruang</p>
|
||||
<h2>Temukan ruang yang tepat</h2>
|
||||
<p>Setiap ruang memiliki karakter dan kegunaan tersendiri—dipilih untuk mendukung berbagai jenis kegiatan komunitas.</p>
|
||||
</div>
|
||||
|
||||
<div className="spaces-grid">
|
||||
{spaces.map(s => (
|
||||
<SpaceCard key={s.id} space={s} onSelect={() => setSelected(s)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="section" style={{ background: 'var(--cream)' }}>
|
||||
<div className="container" style={{ maxWidth: 760 }}>
|
||||
<div className="section__header">
|
||||
<p className="eyebrow">FAQ</p>
|
||||
<h2>Pertanyaan yang sering ditanyakan</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{[
|
||||
{ q: 'Apakah bisa membatalkan pemesanan?', a: 'Pembatalan dapat dilakukan maksimal 24 jam sebelum waktu pemesanan. Pembayaran akan dikembalikan penuh. Hubungi kami via WhatsApp.' },
|
||||
{ q: 'Bagaimana cara pembayaran?', a: 'Setelah mengisi form online, tim kami akan menghubungi via WhatsApp untuk instruksi pembayaran via transfer bank atau QRIS.' },
|
||||
{ q: 'Apakah tersedia perlengkapan tambahan?', a: 'Tersedia kursi, meja, perlengkapan audio-visual, dan dekorasi dasar yang bisa diatur sebelum acara. Konsultasikan kebutuhan khusus Anda.' },
|
||||
{ q: 'Bisakah memesan untuk acara reguler mingguan?', a: 'Bisa! Kami menawarkan paket berlangganan dengan harga spesial untuk komunitas yang membutuhkan ruang secara rutin.' },
|
||||
].map((f, i) => (
|
||||
<div key={i} style={{ padding: '20px 24px', background: '#fff', borderRadius: 'var(--r-md)', border: '1px solid var(--earth-100)' }}>
|
||||
<h4 style={{ marginBottom: 8, fontSize: '1rem' }}>{f.q}</h4>
|
||||
<p style={{ fontSize: '.875rem', color: 'var(--text-300)', lineHeight: 1.7, margin: 0 }}>{f.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Booking modal */}
|
||||
{selected && <BookingModal space={selected} onClose={() => setSelected(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import ModulePage from '../components/ModulePage';
|
||||
import modules from '../data/modules';
|
||||
|
||||
const module = modules.find(m => m.id === 'temannin')!;
|
||||
|
||||
const audiences = [
|
||||
{ icon: '🧑', name: 'Individu', desc: 'Siapa saja yang ingin hadir, belajar, dan tumbuh bersama nilai Anindhaloka.' },
|
||||
{ icon: '🤝', name: 'Komunitas', desc: 'Kelompok dan komunitas yang ingin berkolaborasi dan berjejaring.' },
|
||||
{ icon: '🏢', name: 'Usaha', desc: 'Pelaku usaha yang mencari cara kerja lebih berdampak dan selaras nilai.' },
|
||||
{ icon: '🏛️', name: 'Pemerintah', desc: 'Institusi yang mencari model nyata ketahanan pangan, sandang, dan budaya.' },
|
||||
{ icon: '💚', name: 'LSM', desc: 'Organisasi yang bergerak untuk isu sosial dan lingkungan serupa.' },
|
||||
{ icon: '📰', name: 'Media', desc: 'Mitra yang membantu menyebarkan cerita dan gerakan Anindhaloka.' },
|
||||
];
|
||||
|
||||
export default function TemanNIN() {
|
||||
return (
|
||||
<>
|
||||
<ModulePage module={module} />
|
||||
|
||||
{/* Audience categories */}
|
||||
<section className="section" style={{ background: 'var(--cream)' }}>
|
||||
<div className="container">
|
||||
<div className="section__header">
|
||||
<p className="eyebrow" style={{ color: module.color }}>Terbuka untuk Siapa Saja</p>
|
||||
<h2>Lingkaran yang terus berkembang</h2>
|
||||
<p>Menjadi TemanNIN bukan soal mendaftar dan dapat kartu anggota — ini soal memilih untuk terhubung dengan nilai, komunitas, dan gerakan yang sedang tumbuh.</p>
|
||||
</div>
|
||||
<div className="grid-3">
|
||||
{audiences.map((a, i) => (
|
||||
<div key={i} className="card" style={{ padding: '28px 24px' }}>
|
||||
<div style={{ fontSize: '1.75rem', marginBottom: 12 }}>{a.icon}</div>
|
||||
<h4 style={{ marginBottom: 8 }}>{a.name}</h4>
|
||||
<p style={{ fontSize: '.9rem', color: 'var(--text-500)', lineHeight: 1.7 }}>{a.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ textAlign: 'center', fontSize: '.85rem', color: 'var(--text-300)', marginTop: 32 }}>
|
||||
🌱 TemanNIN adalah ruang yang sedang tumbuh — kategori keanggotaan & benefit detail terus kami kembangkan bersama komunitas. Hubungi kami untuk info terbaru.
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
|
||||
<Link to="/kontak" className="btn btn--primary btn--lg">
|
||||
Terhubung dengan Kami <ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
const untukSiapa = [
|
||||
'Siapa saja yang merindukan cara hidup lebih utuh',
|
||||
'Anak-anak dan orang tua yang ingin belajar dan mewariskan lebih dari materi',
|
||||
'Komunitas yang ingin tumbuh bersama',
|
||||
'Pengrajin, petani, dan tukang masak — pemegang kearifan yang layak dihormati',
|
||||
'Pelaku usaha & institusi yang mencari cara kerja yang lebih berdampak',
|
||||
'Pemerintah yang mencari model nyata ketahanan pangan, sandang, dan budaya',
|
||||
];
|
||||
|
||||
const krisis = [
|
||||
{ icon: '🕊️', title: 'Krisis Spiritual', desc: 'Keterputusan dari makna hidup.' },
|
||||
{ icon: '🤝', title: 'Krisis Sosial', desc: 'Keterputusan dari sesama.' },
|
||||
{ icon: '🌿', title: 'Krisis Ekologis', desc: 'Keterputusan dari alam.' },
|
||||
];
|
||||
|
||||
const nilaiInti = [
|
||||
{ icon: '🧠', title: 'Kesadaran sebagai Titik Berangkat', desc: 'Tidak ada aktivitas tanpa kehadiran penuh.' },
|
||||
{ icon: '🌎', title: 'Relasi Manusia–Alam yang Hidup', desc: 'Alam sebagai mitra, bukan sumber daya.' },
|
||||
{ icon: '🤲', title: 'Gotong Royong & Ekonomi Berkeadilan', desc: 'Nilai didistribusikan adil ke semua yang terlibat.' },
|
||||
{ icon: '♻️', title: 'Regeneratif, Bukan Sekadar Konsep', desc: 'Meninggalkan segalanya lebih baik dari sebelumnya.' },
|
||||
];
|
||||
|
||||
export default function Tentang() {
|
||||
return (
|
||||
<div className="page-top-pad">
|
||||
{/* Hero */}
|
||||
<section
|
||||
style={{
|
||||
background: 'linear-gradient(150deg, var(--forest-950) 0%, var(--forest-900) 60%, #1E3A2A 100%)',
|
||||
padding: '80px 0 96px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 60% 80% at 60% 50%, rgba(74,124,89,.15) 0%, transparent 60%)' }} />
|
||||
<div className="container" style={{ position: 'relative', zIndex: 1, maxWidth: 760 }}>
|
||||
<p className="eyebrow eyebrow--light" style={{ marginBottom: 16 }}>Tentang Kami</p>
|
||||
<h1 style={{ color: '#fff', marginBottom: 20 }}>Anindha, tanpa cela.<br /><em style={{ fontStyle: 'italic', color: 'var(--earth-300)' }}>Loka, ruang dunia.</em></h1>
|
||||
<p style={{ fontSize: '1.15rem', color: 'rgba(255,255,255,.7)', lineHeight: 1.8 }}>
|
||||
Anindhaloka adalah ruang hidup gotong royong yang menghidupkan keterpanggilan untuk menghadirkan kerahayuan dalam kehidupan sehari-hari — melalui laku berkesadaran dalam pangan, sandang, dan olah diri, yang dijalani dengan semangat ingin tahu, pembelajaran, dan kreativitas.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Siapa Kami & Peran Kami */}
|
||||
<section className="section" style={{ background: 'var(--cream)' }}>
|
||||
<div className="container">
|
||||
<div className="grid-2" style={{ gap: 48, alignItems: 'start' }}>
|
||||
<div>
|
||||
<p className="eyebrow" style={{ marginBottom: 12 }}>Siapa Anindhaloka</p>
|
||||
<h2 style={{ marginBottom: 16 }}>Tiga pendekatan yang saling menopang</h2>
|
||||
<p style={{ lineHeight: 1.85, color: 'var(--text-500)', marginBottom: 16 }}>
|
||||
Anindhaloka adalah ruang hidup gotong royong yang menghidupkan keterpanggilan untuk menghadirkan kerahayuan dalam kehidupan sehari-hari — melalui laku berkesadaran dalam pangan, sandang, dan olah diri, yang dijalani dengan semangat ingin tahu, pembelajaran, dan kreativitas: rasa ingin tahu yang mendorong riset, pembelajaran yang mencerahkan, dan kreativitas sebagai cara merespons kehidupan dengan hadir utuh sadar penuh.
|
||||
</p>
|
||||
<p style={{ lineHeight: 1.85, color: 'var(--text-500)', fontStyle: 'italic' }}>
|
||||
Anindha — dari Sanskerta <em>anindya</em>, tanpa cela, mulia. Loka — dunia, ruang, tempat.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="eyebrow" style={{ marginBottom: 12 }}>Peran Kami</p>
|
||||
<h2 style={{ marginBottom: 16 }}>Menyatukan kembali manusia, alam, dan diri</h2>
|
||||
<p style={{ lineHeight: 1.85, color: 'var(--text-500)' }}>
|
||||
Anindhaloka menghadirkan cara hidup yang menyatukan kembali manusia dengan alam, dengan sesama, dan dengan dirinya sendiri — di mana makanan tumbuh dari tanah yang dirawat, pakaian dibuat dengan tangan yang sadar, dan ketenangan batin menjadi hak setiap orang.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Untuk Siapa */}
|
||||
<section className="section" style={{ background: 'var(--warm-white)' }}>
|
||||
<div className="container" style={{ maxWidth: 820 }}>
|
||||
<div className="section__header">
|
||||
<p className="eyebrow">Untuk Siapa Anindhaloka Hadir</p>
|
||||
<h2>Rumah bagi siapa saja yang selaras nilai</h2>
|
||||
</div>
|
||||
<div className="grid-2" style={{ gap: '14px 32px' }}>
|
||||
{untukSiapa.map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start', padding: '14px 0', borderBottom: '1px solid var(--earth-100)' }}>
|
||||
<span style={{ color: 'var(--forest-600)', fontFamily: 'var(--font-heading)', fontWeight: 700, flexShrink: 0 }}>0{i + 1}</span>
|
||||
<p style={{ color: 'var(--text-500)', lineHeight: 1.6, margin: 0 }}>{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mengapa Lahir */}
|
||||
<section
|
||||
className="section"
|
||||
style={{ background: 'linear-gradient(150deg, var(--forest-950) 0%, var(--forest-900) 60%, #1E3A2A 100%)' }}
|
||||
>
|
||||
<div className="container">
|
||||
<div className="section__header">
|
||||
<p className="eyebrow eyebrow--light">Mengapa Anindhaloka Lahir</p>
|
||||
<h2 style={{ color: 'rgba(255,255,255,.95)' }}>Dari kesadaran akan tiga krisis yang saling terkait</h2>
|
||||
<p style={{ color: 'rgba(255,255,255,.65)' }}>
|
||||
Anindhaloka percaya perubahan sejati dimulai dari dalam — dari kesadaran yang tumbuh dalam diri, bukan dari atas ke bawah.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid-3">
|
||||
{krisis.map((k, i) => (
|
||||
<div key={i} style={{ padding: '28px 24px', background: 'rgba(255,255,255,.05)', borderRadius: 'var(--r-lg)', border: '1px solid rgba(255,255,255,.08)' }}>
|
||||
<div style={{ fontSize: '1.75rem', marginBottom: 12 }}>{k.icon}</div>
|
||||
<h4 style={{ marginBottom: 8, color: '#fff' }}>{k.title}</h4>
|
||||
<p style={{ fontSize: '.9rem', color: 'rgba(255,255,255,.65)', lineHeight: 1.7 }}>{k.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Filosofi & Nilai Inti */}
|
||||
<section className="section" style={{ background: 'var(--cream)' }}>
|
||||
<div className="container">
|
||||
<div className="section__header">
|
||||
<p className="eyebrow">Filosofi & Nilai Inti</p>
|
||||
<h2>Empat nilai yang menjadi akar</h2>
|
||||
</div>
|
||||
<div className="grid-4">
|
||||
{nilaiInti.map((n, i) => (
|
||||
<div key={i} style={{ textAlign: 'center', padding: '28px 16px' }}>
|
||||
<div style={{ fontSize: '2.2rem', marginBottom: 12 }}>{n.icon}</div>
|
||||
<h4 style={{ fontSize: '1.05rem', marginBottom: 8, color: 'var(--text-900)' }}>{n.title}</h4>
|
||||
<p style={{ fontSize: '.85rem', color: 'var(--text-300)', lineHeight: 1.6 }}>{n.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="section cta-section">
|
||||
<div className="container">
|
||||
<div className="cta-section__content">
|
||||
<p className="eyebrow eyebrow--light" style={{ marginBottom: 16 }}>Bergabunglah</p>
|
||||
<h2 className="cta-section__title">Jadilah bagian dari cerita ini</h2>
|
||||
<p className="cta-section__desc">
|
||||
Anindhaloka tidak akan ada tanpa komunitasnya. Setiap orang yang bergabung membawa energi baru dan memperkuat ekosistem yang kita bangun bersama.
|
||||
</p>
|
||||
<div className="cta-section__actions">
|
||||
<Link to="/temannin" className="btn btn--amber btn--lg">
|
||||
Daftar Sebagai Member <ArrowRight size={16} />
|
||||
</Link>
|
||||
<Link to="/kontak" className="btn btn--outline-light btn--lg">
|
||||
Hubungi Kami
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import AdminLayout from './AdminLayout';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ContentBlock } from '../../lib/api';
|
||||
|
||||
const PAGE_LABELS: Record<string, string> = {
|
||||
home: 'Beranda',
|
||||
global: 'Global & Footer',
|
||||
tentang: 'Tentang',
|
||||
kontak: 'Kontak',
|
||||
};
|
||||
|
||||
function groupBySection(blocks: ContentBlock[]) {
|
||||
return blocks.reduce<Record<string, ContentBlock[]>>((acc, block) => {
|
||||
if (!acc[block.section]) acc[block.section] = [];
|
||||
acc[block.section].push(block);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export default function AdminContent() {
|
||||
const { page = 'home' } = useParams<{ page: string }>();
|
||||
const [blocks, setBlocks] = useState<ContentBlock[]>([]);
|
||||
const [edits, setEdits] = useState<Record<number, string>>({});
|
||||
const [saving, setSaving] = useState<Record<number, boolean>>({});
|
||||
const [saved, setSaved] = useState<Record<number, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setEdits({});
|
||||
api.getContent(page)
|
||||
.then(data => {
|
||||
setBlocks(data);
|
||||
const initial: Record<number, string> = {};
|
||||
data.forEach(b => { initial[b.id] = b.value || ''; });
|
||||
setEdits(initial);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [page]);
|
||||
|
||||
async function saveBlock(block: ContentBlock) {
|
||||
setSaving(prev => ({ ...prev, [block.id]: true }));
|
||||
try {
|
||||
await api.updateContent(block.id, edits[block.id] ?? '');
|
||||
setSaved(prev => ({ ...prev, [block.id]: true }));
|
||||
setTimeout(() => setSaved(prev => ({ ...prev, [block.id]: false })), 2000);
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'Gagal menyimpan');
|
||||
} finally {
|
||||
setSaving(prev => ({ ...prev, [block.id]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
const grouped = groupBySection(blocks);
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div style={{ padding: '40px 48px' }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, color: 'var(--maroon-800)', marginBottom: 4 }}>
|
||||
{PAGE_LABELS[page] || page}
|
||||
</h1>
|
||||
<p style={{ color: 'var(--stone-500)', marginBottom: 32, fontSize: 14 }}>
|
||||
Edit teks konten halaman ini. Klik Simpan pada setiap field untuk menyimpan perubahan.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ color: 'var(--stone-400)', fontSize: 14 }}>Memuat konten...</div>
|
||||
) : blocks.length === 0 ? (
|
||||
<div style={{ color: 'var(--stone-400)', fontSize: 14 }}>Belum ada konten untuk halaman ini.</div>
|
||||
) : (
|
||||
Object.entries(grouped).map(([section, sectionBlocks]) => (
|
||||
<div key={section} style={{
|
||||
background: 'white',
|
||||
borderRadius: 12,
|
||||
padding: '24px',
|
||||
marginBottom: 20,
|
||||
boxShadow: '0 1px 8px rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
<h2 style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
color: 'var(--maroon-600)',
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
{section}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{sectionBlocks.map(block => (
|
||||
<div key={block.id}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--stone-500)', marginBottom: 6 }}>
|
||||
{block.key}
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
{(block.content_type === 'html' || (edits[block.id] || '').length > 80) ? (
|
||||
<textarea
|
||||
value={edits[block.id] ?? ''}
|
||||
onChange={e => setEdits(prev => ({ ...prev, [block.id]: e.target.value }))}
|
||||
rows={3}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 12px',
|
||||
border: '1.5px solid var(--stone-200)',
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
resize: 'vertical',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={edits[block.id] ?? ''}
|
||||
onChange={e => setEdits(prev => ({ ...prev, [block.id]: e.target.value }))}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 12px',
|
||||
border: '1.5px solid var(--stone-200)',
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => saveBlock(block)}
|
||||
disabled={saving[block.id]}
|
||||
style={{
|
||||
padding: '10px 18px',
|
||||
background: saved[block.id] ? '#16A34A' : saving[block.id] ? 'var(--stone-300)' : 'var(--maroon-700)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: saving[block.id] ? 'not-allowed' : 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'background 0.2s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{saved[block.id] ? 'Tersimpan!' : saving[block.id] ? '...' : 'Simpan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import AdminLayout from './AdminLayout';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ContentBlock, MediaFile } from '../../lib/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [contentCount, setContentCount] = useState<number | null>(null);
|
||||
const [mediaCount, setMediaCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getContent().then((c: ContentBlock[]) => setContentCount(c.length)).catch(() => {});
|
||||
api.getMedia().then((m: MediaFile[]) => setMediaCount(m.length)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const cards = [
|
||||
{ label: 'Total Content Blocks', value: contentCount, action: () => navigate('/admin/content/home'), cta: 'Edit Beranda' },
|
||||
{ label: 'Total Media', value: mediaCount, action: () => navigate('/admin/media'), cta: 'Kelola Media' },
|
||||
{ label: 'Halaman Tersedia', value: 5, action: null, cta: null },
|
||||
];
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div style={{ padding: '40px 48px' }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, color: 'var(--maroon-800)', marginBottom: 8 }}>Dashboard</h1>
|
||||
<p style={{ color: 'var(--stone-500)', marginBottom: 40, fontSize: 14 }}>
|
||||
Selamat datang di CMS Anindhaloka. Kelola konten website dari sini.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 20, marginBottom: 40 }}>
|
||||
{cards.map((card, i) => (
|
||||
<div key={i} style={{
|
||||
background: 'white',
|
||||
borderRadius: 12,
|
||||
padding: '28px 24px',
|
||||
boxShadow: '0 1px 8px rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
<div style={{ fontSize: 13, color: 'var(--stone-500)', marginBottom: 8 }}>{card.label}</div>
|
||||
<div style={{ fontSize: 36, fontWeight: 700, color: 'var(--maroon-700)', marginBottom: 16 }}>
|
||||
{card.value ?? '...'}
|
||||
</div>
|
||||
{card.action && (
|
||||
<button
|
||||
onClick={card.action}
|
||||
style={{
|
||||
background: 'var(--maroon-700)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
padding: '8px 16px',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{card.cta}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'white', borderRadius: 12, padding: '28px 24px', boxShadow: '0 1px 8px rgba(0,0,0,0.06)' }}>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16, color: 'var(--stone-800)' }}>Akses Cepat</h2>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: 'Edit Beranda', path: '/admin/content/home' },
|
||||
{ label: 'Edit Global & Footer', path: '/admin/content/global' },
|
||||
{ label: 'Edit Tentang', path: '/admin/content/tentang' },
|
||||
{ label: 'Upload Logo', path: '/admin/media' },
|
||||
].map(item => (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
style={{
|
||||
background: 'var(--cream-100)',
|
||||
color: 'var(--maroon-700)',
|
||||
border: '1.5px solid var(--maroon-200)',
|
||||
borderRadius: 8,
|
||||
padding: '10px 18px',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { useNavigate, NavLink } from 'react-router-dom';
|
||||
import { api } from '../../lib/api';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/admin/dashboard', label: 'Dashboard' },
|
||||
{ to: '/admin/content/home', label: 'Beranda' },
|
||||
{ to: '/admin/content/global', label: 'Global & Footer' },
|
||||
{ to: '/admin/content/tentang', label: 'Tentang' },
|
||||
{ to: '/admin/content/kontak', label: 'Kontak' },
|
||||
{ to: '/admin/media', label: 'Media & Logo' },
|
||||
{ to: '/admin/settings', label: 'Pengaturan' },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!api.isLoggedIn()) navigate('/admin');
|
||||
}, [navigate]);
|
||||
|
||||
function handleLogout() {
|
||||
api.logout();
|
||||
navigate('/admin');
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--cream-100)' }}>
|
||||
{/* Sidebar */}
|
||||
<aside style={{
|
||||
width: 220,
|
||||
background: 'var(--maroon-800)',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ padding: '28px 20px 20px', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<div style={{ fontWeight: 800, fontSize: 16, letterSpacing: '-0.5px' }}>anindhaloka</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.6, marginTop: 2 }}>Admin Panel</div>
|
||||
</div>
|
||||
<nav style={{ flex: 1, padding: '12px 0' }}>
|
||||
{NAV_ITEMS.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
style={({ isActive }) => ({
|
||||
display: 'block',
|
||||
padding: '10px 20px',
|
||||
fontSize: 14,
|
||||
textDecoration: 'none',
|
||||
color: isActive ? 'white' : 'rgba(255,255,255,0.65)',
|
||||
background: isActive ? 'rgba(255,255,255,0.12)' : 'transparent',
|
||||
borderLeft: isActive ? '3px solid #F0B429' : '3px solid transparent',
|
||||
transition: 'all 0.15s',
|
||||
})}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div style={{ padding: '16px 20px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
borderRadius: 6,
|
||||
padding: '8px 14px',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
Keluar
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<main style={{ flex: 1, overflow: 'auto' }}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../lib/api';
|
||||
|
||||
export default function AdminLogin() {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.login(username, password);
|
||||
navigate('/admin/dashboard');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Login gagal');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--cream-100)',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: 16,
|
||||
padding: '48px 40px',
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<img
|
||||
src="/assets/logo/anindhaloka-wordmark.png"
|
||||
alt="Anindhaloka"
|
||||
style={{ height: 40, width: 'auto', display: 'block', margin: '0 auto 12px' }}
|
||||
/>
|
||||
<div style={{ fontSize: 13, color: 'var(--stone-500)', marginTop: 4 }}>Admin Panel</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 6, color: 'var(--stone-700)' }}>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1.5px solid var(--stone-200)',
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 6, color: 'var(--stone-700)' }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1.5px solid var(--stone-200)',
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#FEF2F2',
|
||||
color: '#B91C1C',
|
||||
borderRadius: 8,
|
||||
padding: '10px 14px',
|
||||
fontSize: 13,
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: loading ? 'var(--stone-300)' : 'var(--maroon-700)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
>
|
||||
{loading ? 'Masuk...' : 'Masuk'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useState, useRef, ChangeEvent } from 'react';
|
||||
import AdminLayout from './AdminLayout';
|
||||
import { api } from '../../lib/api';
|
||||
import type { MediaFile } from '../../lib/api';
|
||||
|
||||
export default function AdminMedia() {
|
||||
const [media, setMedia] = useState<MediaFile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [purpose, setPurpose] = useState('logo');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function loadMedia() {
|
||||
setLoading(true);
|
||||
api.getMedia().then(setMedia).finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { loadMedia(); }, []);
|
||||
|
||||
async function handleUpload(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await api.uploadMedia(file, purpose);
|
||||
loadMedia();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'Upload gagal');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number, filename: string) {
|
||||
if (!confirm(`Hapus file "${filename}"?`)) return;
|
||||
try {
|
||||
await api.deleteMedia(id);
|
||||
loadMedia();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'Gagal menghapus');
|
||||
}
|
||||
}
|
||||
|
||||
function copyUrl(url: string) {
|
||||
navigator.clipboard.writeText(api.mediaUrl(url));
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div style={{ padding: '40px 48px' }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, color: 'var(--maroon-800)', marginBottom: 4 }}>Media & Logo</h1>
|
||||
<p style={{ color: 'var(--stone-500)', marginBottom: 32, fontSize: 14 }}>
|
||||
Upload logo, gambar hero, atau gambar lainnya untuk website.
|
||||
</p>
|
||||
|
||||
{/* Upload area */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: 12,
|
||||
padding: '28px 24px',
|
||||
marginBottom: 24,
|
||||
boxShadow: '0 1px 8px rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
<h2 style={{ fontSize: 15, fontWeight: 600, marginBottom: 16, color: 'var(--stone-800)' }}>Upload File Baru</h2>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<select
|
||||
value={purpose}
|
||||
onChange={e => setPurpose(e.target.value)}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
border: '1.5px solid var(--stone-200)',
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
background: 'white',
|
||||
}}
|
||||
>
|
||||
<option value="logo">Logo</option>
|
||||
<option value="hero">Hero Background</option>
|
||||
<option value="card">Card Cover</option>
|
||||
<option value="other">Lainnya</option>
|
||||
</select>
|
||||
<label style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '10px 20px',
|
||||
background: uploading ? 'var(--stone-300)' : 'var(--maroon-700)',
|
||||
color: 'white',
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: uploading ? 'not-allowed' : 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{uploading ? 'Mengupload...' : 'Pilih & Upload Gambar'}
|
||||
</label>
|
||||
<span style={{ fontSize: 13, color: 'var(--stone-400)' }}>JPG, PNG, WebP, SVG, GIF</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media grid */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: 12,
|
||||
padding: '28px 24px',
|
||||
boxShadow: '0 1px 8px rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
<h2 style={{ fontSize: 15, fontWeight: 600, marginBottom: 16, color: 'var(--stone-800)' }}>
|
||||
File Tersimpan ({media.length})
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ color: 'var(--stone-400)', fontSize: 14 }}>Memuat media...</div>
|
||||
) : media.length === 0 ? (
|
||||
<div style={{ color: 'var(--stone-400)', fontSize: 14, textAlign: 'center', padding: '32px 0' }}>
|
||||
Belum ada file yang diupload.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 16 }}>
|
||||
{media.map(file => (
|
||||
<div key={file.id} style={{
|
||||
border: '1.5px solid var(--stone-100)',
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
height: 140,
|
||||
background: 'var(--cream-100)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<img
|
||||
src={api.mediaUrl(file.file_url)}
|
||||
alt={file.original_name}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ padding: '10px 12px' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--stone-700)', marginBottom: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{file.original_name}
|
||||
</div>
|
||||
{file.purpose && (
|
||||
<div style={{ fontSize: 11, color: 'var(--maroon-600)', marginBottom: 8 }}>{file.purpose}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => copyUrl(file.file_url)}
|
||||
title="Salin URL"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 0',
|
||||
background: 'var(--cream-100)',
|
||||
border: '1px solid var(--stone-200)',
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--stone-600)',
|
||||
}}
|
||||
>
|
||||
Salin URL
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(file.id, file.original_name)}
|
||||
style={{
|
||||
padding: '5px 8px',
|
||||
background: '#FEF2F2',
|
||||
border: '1px solid #FECACA',
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
cursor: 'pointer',
|
||||
color: '#B91C1C',
|
||||
}}
|
||||
>
|
||||
Hapus
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState, FormEvent } from 'react';
|
||||
import AdminLayout from './AdminLayout';
|
||||
import { api } from '../../lib/api';
|
||||
import type { AdminUser } from '../../lib/api';
|
||||
|
||||
export default function AdminSettings() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
|
||||
// Change password
|
||||
const [currentPw, setCurrentPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [pwError, setPwError] = useState('');
|
||||
const [pwSuccess, setPwSuccess] = useState('');
|
||||
const [pwLoading, setPwLoading] = useState(false);
|
||||
|
||||
// Add user
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [newUserPw, setNewUserPw] = useState('');
|
||||
const [addError, setAddError] = useState('');
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
|
||||
function loadUsers() {
|
||||
setLoadingUsers(true);
|
||||
api.getUsers().then(setUsers).finally(() => setLoadingUsers(false));
|
||||
}
|
||||
|
||||
useEffect(() => { loadUsers(); }, []);
|
||||
|
||||
async function handleChangePassword(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setPwError('');
|
||||
setPwSuccess('');
|
||||
if (newPw !== confirmPw) { setPwError('Password baru tidak cocok'); return; }
|
||||
if (newPw.length < 6) { setPwError('Password minimal 6 karakter'); return; }
|
||||
setPwLoading(true);
|
||||
try {
|
||||
await api.changePassword(currentPw, newPw);
|
||||
setPwSuccess('Password berhasil diubah');
|
||||
setCurrentPw(''); setNewPw(''); setConfirmPw('');
|
||||
} catch (err: unknown) {
|
||||
setPwError(err instanceof Error ? err.message : 'Gagal mengubah password');
|
||||
} finally {
|
||||
setPwLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddUser(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setAddError('');
|
||||
setAddLoading(true);
|
||||
try {
|
||||
await api.createUser(newUsername, newUserPw);
|
||||
setNewUsername(''); setNewUserPw('');
|
||||
loadUsers();
|
||||
} catch (err: unknown) {
|
||||
setAddError(err instanceof Error ? err.message : 'Gagal menambah user');
|
||||
} finally {
|
||||
setAddLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number, username: string) {
|
||||
if (!confirm(`Hapus user "${username}"?`)) return;
|
||||
try {
|
||||
await api.deleteUser(id);
|
||||
loadUsers();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'Gagal menghapus');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(id: number) {
|
||||
try {
|
||||
await api.toggleUser(id);
|
||||
loadUsers();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'Gagal mengubah status');
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '1.5px solid var(--stone-200)',
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
boxSizing: 'border-box' as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div style={{ padding: '40px 48px', maxWidth: 700 }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, color: 'var(--maroon-800)', marginBottom: 4 }}>Pengaturan</h1>
|
||||
<p style={{ color: 'var(--stone-500)', marginBottom: 40, fontSize: 14 }}>
|
||||
Kelola akun admin dan ubah password.
|
||||
</p>
|
||||
|
||||
{/* Ganti Password */}
|
||||
<div style={{ background: 'white', borderRadius: 12, padding: '28px 24px', marginBottom: 24, boxShadow: '0 1px 8px rgba(0,0,0,0.06)' }}>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 20, color: 'var(--stone-800)' }}>Ganti Password</h2>
|
||||
<form onSubmit={handleChangePassword} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--stone-500)', marginBottom: 6 }}>Password Lama</label>
|
||||
<input type="password" value={currentPw} onChange={e => setCurrentPw(e.target.value)} required style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--stone-500)', marginBottom: 6 }}>Password Baru</label>
|
||||
<input type="password" value={newPw} onChange={e => setNewPw(e.target.value)} required style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--stone-500)', marginBottom: 6 }}>Konfirmasi Password Baru</label>
|
||||
<input type="password" value={confirmPw} onChange={e => setConfirmPw(e.target.value)} required style={inputStyle} />
|
||||
</div>
|
||||
{pwError && <div style={{ background: '#FEF2F2', color: '#B91C1C', borderRadius: 8, padding: '10px 14px', fontSize: 13 }}>{pwError}</div>}
|
||||
{pwSuccess && <div style={{ background: '#F0FDF4', color: '#16A34A', borderRadius: 8, padding: '10px 14px', fontSize: 13 }}>{pwSuccess}</div>}
|
||||
<button type="submit" disabled={pwLoading} style={{
|
||||
alignSelf: 'flex-start',
|
||||
padding: '10px 24px',
|
||||
background: pwLoading ? 'var(--stone-300)' : 'var(--maroon-700)',
|
||||
color: 'white', border: 'none', borderRadius: 8,
|
||||
fontSize: 14, fontWeight: 600, cursor: pwLoading ? 'not-allowed' : 'pointer',
|
||||
}}>
|
||||
{pwLoading ? 'Menyimpan...' : 'Simpan Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Daftar Admin */}
|
||||
<div style={{ background: 'white', borderRadius: 12, padding: '28px 24px', marginBottom: 24, boxShadow: '0 1px 8px rgba(0,0,0,0.06)' }}>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 20, color: 'var(--stone-800)' }}>
|
||||
Daftar Admin ({users.length})
|
||||
</h2>
|
||||
{loadingUsers ? (
|
||||
<div style={{ color: 'var(--stone-400)', fontSize: 14 }}>Memuat...</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{users.map(user => (
|
||||
<div key={user.id} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 16px', background: 'var(--cream-100)', borderRadius: 8,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--stone-800)' }}>{user.username}</div>
|
||||
<div style={{ fontSize: 12, color: user.is_active ? '#16A34A' : 'var(--stone-400)', marginTop: 2 }}>
|
||||
{user.is_active ? 'Aktif' : 'Nonaktif'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => handleToggle(user.id)} style={{
|
||||
padding: '6px 12px', fontSize: 12, borderRadius: 6, cursor: 'pointer',
|
||||
background: 'white', border: '1px solid var(--stone-200)', color: 'var(--stone-600)',
|
||||
}}>
|
||||
{user.is_active ? 'Nonaktifkan' : 'Aktifkan'}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(user.id, user.username)} style={{
|
||||
padding: '6px 12px', fontSize: 12, borderRadius: 6, cursor: 'pointer',
|
||||
background: '#FEF2F2', border: '1px solid #FECACA', color: '#B91C1C',
|
||||
}}>
|
||||
Hapus
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tambah Admin */}
|
||||
<div style={{ background: 'white', borderRadius: 12, padding: '28px 24px', boxShadow: '0 1px 8px rgba(0,0,0,0.06)' }}>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 20, color: 'var(--stone-800)' }}>Tambah Admin Baru</h2>
|
||||
<form onSubmit={handleAddUser} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--stone-500)', marginBottom: 6 }}>Username</label>
|
||||
<input type="text" value={newUsername} onChange={e => setNewUsername(e.target.value)} required style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--stone-500)', marginBottom: 6 }}>Password</label>
|
||||
<input type="password" value={newUserPw} onChange={e => setNewUserPw(e.target.value)} required style={inputStyle} />
|
||||
</div>
|
||||
{addError && <div style={{ background: '#FEF2F2', color: '#B91C1C', borderRadius: 8, padding: '10px 14px', fontSize: 13 }}>{addError}</div>}
|
||||
<button type="submit" disabled={addLoading} style={{
|
||||
alignSelf: 'flex-start',
|
||||
padding: '10px 24px',
|
||||
background: addLoading ? 'var(--stone-300)' : 'var(--maroon-700)',
|
||||
color: 'white', border: 'none', borderRadius: 8,
|
||||
fontSize: 14, fontWeight: 600, cursor: addLoading ? 'not-allowed' : 'pointer',
|
||||
}}>
|
||||
{addLoading ? 'Menambahkan...' : 'Tambah Admin'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||