This commit is contained in:
Vassshhh
2025-08-05 15:11:59 +07:00
parent 690bb837f6
commit 7e83b442cc
9 changed files with 1144 additions and 392 deletions

10
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"bootstrap": "^5.3.7",
"lucide-react": "^0.536.0",
"react": "^19.1.1",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.1.1",
@@ -11408,6 +11409,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.536.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.536.0.tgz",
"integrity": "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",

View File

@@ -8,6 +8,7 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"bootstrap": "^5.3.7",
"lucide-react": "^0.536.0",
"react": "^19.1.1",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.1.1",

View File

@@ -17,6 +17,7 @@ import Footer from './components/Footer';
import ProductDetailPage from './components/ProductDetailPage';
import Dashboard from './components/Dashboard';
import ProductsPage from './components/pages/ProductsPage';
@@ -76,6 +77,8 @@ function App() {
// State yang diperlukan untuk HomePage
const [hoveredCard, setHoveredCard] = useState(null);
const [subscriptions, setSubscriptions] = useState(null);
const [selectedProduct, setSelectedProduct] = useState({});
const [showedModal, setShowedModal] = useState(null); // 'product' | 'login' | null
const [postLoginAction, setPostLoginAction] = useState(null);
@@ -102,11 +105,12 @@ function App() {
.then(res => res.json())
.then(data => {
if (data && data[0] && data[0].token) {
if (data && data.token) {
// Update token with data[0].token
document.cookie = `token=${data[0].token}; path=/`;
const payload = parseJwt(token);
document.cookie = `token=${data.token}; path=/`;
console.log(data)
setSubscriptions(data.subscriptions)
const payload = parseJwt(data.token);
if (payload && payload.username) {
setUsername(payload.username);
}
@@ -132,6 +136,17 @@ function App() {
return () => clearTimeout(timer);
}, []);
const handleLogout = () => {
// Hapus cookie token dengan mengatur tanggal kadaluarsa ke masa lalu
document.cookie = 'token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC';
// Jika kamu menggunakan state seperti `setUsername`, bersihkan di sini juga
setUsername(null); // jika applicable
// Redirect ke homepage atau reload halaman
window.location.reload();
};
if (loading) {
return (
<div id="js-preloader" className="js-preloader">
@@ -150,7 +165,7 @@ function App() {
return (
<Router>
<div className="App">
<Header username={username} scrollToProduct={scrollToProduct} scrollToCourse={scrollToCourse} setShowedModal={setShowedModal} />
<Header username={username} scrollToProduct={scrollToProduct} scrollToCourse={scrollToCourse} setShowedModal={setShowedModal} handleLogout={handleLogout} />
<Routes>
<Route
path="/"
@@ -170,7 +185,13 @@ function App() {
<Route
path="/products"
element={
<ProductsPage/>
<ProductsPage subscriptions={subscriptions}/>
}
/>
<Route
path="/dashboard"
element={
<Dashboard />
}
/>
</Routes>
@@ -190,6 +211,7 @@ function App() {
>
{showedModal === 'product' && (
<ProductDetailPage
subscriptions={subscriptions}
setPostLoginAction={setPostLoginAction}
setShowedModal={setShowedModal}
product={selectedProduct}

280
src/components/Dashboard.js Normal file
View File

@@ -0,0 +1,280 @@
import React, { useState, useEffect } from 'react';
import { TrendingUp, TrendingDown, DollarSign, ShoppingCart, Users } from 'lucide-react';
import styles from './Dashboard.module.css';
const Dashboard = () => {
const [dashboardData, setDashboardData] = useState({
totalRevenue: {
amount: 10215845,
currency: 'IDR',
change: 33.87,
period: '22 - 29 May 2025'
},
totalItemsSold: {
amount: 128980,
change: -33.87,
period: '22 - 29 May 2025'
},
totalVisitors: {
amount: 2905897,
change: 33.87,
period: '22 - 29 May 2025'
},
chartData: [
{ date: '22/06', items: 200, revenue: 800 },
{ date: '23/06', items: 750, revenue: 450 },
{ date: '24/06', items: 550, revenue: 200 },
{ date: '24/06', items: 300, revenue: 350 },
{ date: '24/06', items: 900, revenue: 450 },
{ date: '24/06', items: 550, revenue: 200 },
{ date: '24/06', items: 700, revenue: 300 }
],
latestTransactions: [
{
id: 1,
name: 'Samantha William',
amount: 250875,
date: 'May 22, 2025',
status: 'confirmed',
avatar: 'SW'
},
{
id: 2,
name: 'Kevin Anderson',
amount: 350620,
date: 'May 22, 2025',
status: 'waiting payment',
avatar: 'KA'
},
{
id: 3,
name: 'Angela Samantha',
amount: 870563,
date: 'May 22, 2025',
status: 'confirmed',
avatar: 'AS'
},
{
id: 4,
name: 'Michael Smith',
amount: 653975,
date: 'May 22, 2025',
status: 'payment expired',
avatar: 'MS'
},
{
id: 5,
name: 'Jonathan Sebastian',
amount: 950000,
date: 'May 22, 2025',
status: 'confirmed',
avatar: 'JS'
}
]
});
// Function untuk connect ke n8n webhook
const connectToN8NWebhook = async (webhookUrl) => {
try {
const response = await fetch(webhookUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
const data = await response.json();
setDashboardData(data);
}
} catch (error) {
console.error('Error connecting to n8n webhook:', error);
}
};
// Function untuk send data ke n8n webhook
const sendDataToN8N = async (webhookUrl, data) => {
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (response.ok) {
console.log('Data sent successfully to n8n');
}
} catch (error) {
console.error('Error sending data to n8n:', error);
}
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat('id-ID').format(amount);
};
const getStatusClass = (status) => {
switch (status) {
case 'confirmed':
return styles.statusConfirmed;
case 'waiting payment':
return styles.statusWaiting;
case 'payment expired':
return styles.statusExpired;
default:
return styles.statusConfirmed;
}
};
const StatCard = ({ title, value, currency, change, period, icon: Icon, isNegative }) => (
<div className={styles.statCard}>
<div className={styles.statCardHeader}>
<h3 className={styles.statCardTitle}>{title}</h3>
<Icon className={styles.statCardIcon} />
</div>
<div className={styles.statCardValue}>
{currency && `${currency} `}{formatCurrency(value)}
</div>
<div className={styles.statCardFooter}>
<div className={styles.statCardChange}>
{isNegative ? (
<TrendingDown className={`${styles.trendIcon} ${styles.trendDown}`} />
) : (
<TrendingUp className={`${styles.trendIcon} ${styles.trendUp}`} />
)}
<span className={`${styles.changeText} ${isNegative ? styles.changeTextNegative : styles.changeTextPositive}`}>
{Math.abs(change)}%
</span>
<span className={styles.fromLastWeek}>from last week</span>
</div>
</div>
<div className={styles.statCardPeriod}>{period}</div>
</div>
);
const BarChart = ({ data }) => {
const maxValue = Math.max(...data.map(item => Math.max(item.items, item.revenue)));
return (
<div className={styles.barChart}>
{data.map((item, index) => (
<div key={index} className={styles.barGroup}>
<div className={styles.barContainer}>
<div
className={`${styles.bar} ${styles.barItems}`}
style={{
height: `${(item.items / maxValue) * 200}px`
}}
/>
<div
className={`${styles.bar} ${styles.barRevenue}`}
style={{
height: `${(item.revenue / maxValue) * 200}px`
}}
/>
</div>
<span className={styles.barLabel}>{item.date}</span>
</div>
))}
</div>
);
};
return (
<div className={styles.container}>
{/* Stats Cards */}
<div className={styles.statsGrid}>
<StatCard
title="Total Revenue"
value={dashboardData.totalRevenue.amount}
currency={dashboardData.totalRevenue.currency}
change={dashboardData.totalRevenue.change}
period={dashboardData.totalRevenue.period}
icon={DollarSign}
isNegative={false}
/>
<StatCard
title="Total Items Sold"
value={dashboardData.totalItemsSold.amount}
change={dashboardData.totalItemsSold.change}
period={dashboardData.totalItemsSold.period}
icon={ShoppingCart}
isNegative={true}
/>
<StatCard
title="Total Visitor"
value={dashboardData.totalVisitors.amount}
change={dashboardData.totalVisitors.change}
period={dashboardData.totalVisitors.period}
icon={Users}
isNegative={false}
/>
</div>
{/* Charts and Transactions */}
<div className={styles.chartsGrid}>
{/* Report Statistics */}
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div>
<h3 className={styles.chartTitle}>Report Statistics</h3>
<p className={styles.chartSubtitle}>Period: 22 - 29 May 2025</p>
</div>
<div className={styles.chartLegend}>
<div className={styles.legendItem}>
<div className={`${styles.legendColor} ${styles.legendColorGreen}`}></div>
<span className={styles.legendText}>Items Sold</span>
</div>
<div className={styles.legendItem}>
<div className={`${styles.legendColor} ${styles.legendColorLightGreen}`}></div>
<span className={styles.legendText}>Revenue</span>
</div>
</div>
</div>
<BarChart data={dashboardData.chartData} />
</div>
{/* Latest Transactions */}
<div className={styles.chartCard}>
<div className={styles.transactionsHeader}>
<h3 className={styles.transactionsTitle}>Latest Transactions</h3>
<a href="#" className={styles.seeAllLink}>see all transactions</a>
</div>
<div className={styles.transactionsList}>
{dashboardData.latestTransactions.map((transaction) => (
<div key={transaction.id} className={styles.transactionItem}>
<div className={styles.transactionLeft}>
<div className={styles.transactionAvatar}>
{transaction.avatar}
</div>
<div className={styles.transactionInfo}>
<h4>{transaction.name}</h4>
<p>on {transaction.date}</p>
</div>
</div>
<div className={styles.transactionRight}>
<span className={styles.transactionAmount}>
IDR {formatCurrency(transaction.amount)}
</span>
<div className={`${styles.statusIndicator} ${getStatusClass(transaction.status)}`}></div>
<span className={styles.transactionStatus}>
{transaction.status}
</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,326 @@
/* dashboard.module.css */
.container {
min-height: 100vh;
background-color: #f9fafb;
padding: 24px;
}
.statsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.statCard {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
transition: transform 0.2s ease-in-out;
}
.statCard:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.statCardHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.statCardTitle {
color: #6b7280;
font-size: 14px;
font-weight: 500;
margin: 0;
}
.statCardIcon {
width: 20px;
height: 20px;
color: #9ca3af;
}
.statCardValue {
font-size: 28px;
font-weight: bold;
color: #111827;
margin-bottom: 8px;
}
.statCardFooter {
display: flex;
align-items: center;
justify-content: space-between;
}
.statCardChange {
display: flex;
align-items: center;
}
.trendIcon {
width: 16px;
height: 16px;
margin-right: 4px;
}
.trendUp {
color: #10b981;
}
.trendDown {
color: #ef4444;
}
.changeText {
font-size: 14px;
font-weight: 500;
}
.changeTextPositive {
color: #10b981;
}
.changeTextNegative {
color: #ef4444;
}
.fromLastWeek {
color: #6b7280;
font-size: 14px;
margin-left: 4px;
}
.statCardPeriod {
color: #9ca3af;
font-size: 12px;
margin-top: 8px;
}
.chartsGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 1024px) {
.chartsGrid {
grid-template-columns: 1fr;
}
}
.chartCard {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
}
.chartHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.chartTitle {
font-size: 18px;
font-weight: 600;
color: #111827;
margin: 0;
}
.chartSubtitle {
color: #6b7280;
font-size: 14px;
margin: 4px 0 0 0;
}
.chartLegend {
display: flex;
gap: 16px;
font-size: 14px;
}
.legendItem {
display: flex;
align-items: center;
}
.legendColor {
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 8px;
}
.legendColorGreen {
background-color: #10b981;
}
.legendColorLightGreen {
background-color: #6ee7b7;
}
.legendText {
color: #6b7280;
}
.barChart {
display: flex;
align-items: end;
justify-content: space-between;
height: 256px;
padding: 0 16px;
}
.barGroup {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.barContainer {
display: flex;
align-items: end;
gap: 4px;
}
.bar {
border-radius: 4px 4px 0 0;
}
.barItems {
background-color: #10b981;
width: 20px;
}
.barRevenue {
background-color: #6ee7b7;
width: 20px;
}
.barLabel {
font-size: 12px;
color: #6b7280;
}
.transactionsHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.transactionsTitle {
font-size: 18px;
font-weight: 600;
color: #111827;
margin: 0;
}
.seeAllLink {
color: #3b82f6;
font-size: 14px;
text-decoration: none;
}
.seeAllLink:hover {
text-decoration: underline;
}
.transactionsList {
display: flex;
flex-direction: column;
gap: 16px;
}
.transactionItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-radius: 8px;
transition: background-color 0.2s ease;
}
.transactionItem:hover {
background-color: #f9fafb;
}
.transactionLeft {
display: flex;
align-items: center;
gap: 12px;
}
.transactionAvatar {
width: 40px;
height: 40px;
background-color: #e5e7eb;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
color: #6b7280;
}
.transactionInfo h4 {
font-weight: 500;
color: #111827;
margin: 0 0 4px 0;
font-size: 14px;
}
.transactionInfo p {
color: #6b7280;
font-size: 12px;
margin: 0;
}
.transactionRight {
display: flex;
align-items: center;
gap: 12px;
}
.transactionAmount {
font-weight: 600;
color: #111827;
font-size: 14px;
}
.statusIndicator {
width: 12px;
height: 12px;
border-radius: 50%;
}
.statusConfirmed {
background-color: #10b981;
}
.statusWaiting {
background-color: #f59e0b;
}
.statusExpired {
background-color: #ef4444;
}
.transactionStatus {
font-size: 12px;
color: #6b7280;
text-transform: capitalize;
}

View File

@@ -1,29 +1,23 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Navbar, Nav, Container } from 'react-bootstrap';
import styles from './Styles.module.css';
const Header = ({ username, scrollToProduct, scrollToCourse, setShowedModal }) => {
const Header = ({ username, scrollToProduct, scrollToCourse, setShowedModal, handleLogout }) => {
const navigate = useNavigate();
const [hoveredNav, setHoveredNav] = useState(null);
const [menuOpen, setMenuOpen] = useState(false); // toggle mobile menu
return (
<header className={styles.header}>
<img src="./kediri-technopark-logo.png" className={styles.logo} alt="Logo" />
{/* Desktop Navigation */}
<nav className={styles.nav}>
<a
className={`${styles.navLink} ${hoveredNav === 2 ? styles.navLinkHover : ''}`}
onMouseEnter={() => setHoveredNav(2)}
onMouseLeave={() => setHoveredNav(null)}
onClick={() => {
navigate('/');
}}
onClick={() => navigate('/')}
>
HOME
</a>
@@ -32,16 +26,11 @@ const Header = ({ username, scrollToProduct, scrollToCourse, setShowedModal }) =
onMouseEnter={() => setHoveredNav(3)}
onMouseLeave={() => setHoveredNav(null)}
onClick={() => {
if (username == null) {
scrollToCourse();
}
else {
navigate('/products');
}
if (!username) scrollToCourse();
else navigate('/products');
}}
>
{username == null ? "PRODUCTS" : "MY PRODUCTS"}
{username ? 'MY PRODUCTS' : 'PRODUCTS'}
</a>
<a
className={`${styles.navLink} ${hoveredNav === 4 ? styles.navLinkHover : ''}`}
@@ -52,12 +41,54 @@ const Header = ({ username, scrollToProduct, scrollToCourse, setShowedModal }) =
</a>
</nav>
<div className={styles.authButtons}>
{/* Burger Menu Button */}
<div className={styles.burger} onClick={() => setMenuOpen(!menuOpen)}>
</div>
{/* Mobile Dropdown Menu */}
{menuOpen && (
<div className={styles.mobileMenu}>
{username ? (
<span style={{ color: '#2563eb', fontWeight: '600' }}>
Halo, {username}
</span>
<>
<div className={styles.username}>Halo, {username}</div>
<button className={styles.logoutButton} onClick={() => {
navigate('/products');
}}>
MY PRODUCTS
</button>
<button className={styles.logoutButton} onClick={() => {
setMenuOpen(false);
handleLogout();
}}>
Logout
</button>
</>
) : (
<button
className={styles.loginButton}
onClick={() => {
setMenuOpen(false);
setShowedModal('login');
}}
>
LOGIN
</button>
)}
</div>
)}
{/* Desktop Auth Buttons */}
<div className={styles.authButtons}>
{username && (
<div className={styles.loggedInContainer}>
<span className={styles.username}>Halo, {username}</span>
<button className={styles.logoutButton} onClick={handleLogout}>Logout</button>
</div>
)}
{!username && (
<button className={styles.loginButton} onClick={() => setShowedModal('login')}>
LOGIN
</button>

View File

@@ -1,104 +1,96 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import styles from './ProductDetail.module.css';
const ProductDetail = ({ product, setPostLoginAction, setShowedModal }) => {
const [inCart, setInCart] = useState(false);
const ProductDetail = ({ subscriptions, product, setPostLoginAction, setShowedModal }) => {
const [showChildSelector, setShowChildSelector] = useState(false);
const [selectedChildIds, setSelectedChildIds] = useState([]);
useEffect(() => {
const existingCookie = document.cookie
.split('; ')
.find(row => row.startsWith('itemsId='));
let items = [];
if (existingCookie) {
const [matchingSubscriptions, setMatchingSubscriptions] = useState([]);
const [selectedSubscriptionId, setSelectedSubscriptionId] = useState(null);
const [showSubscriptionSelector, setShowSubscriptionSelector] = useState(false);
const [showNamingInput, setShowNamingInput] = useState(false);
const [customName, setCustomName] = useState('');
const parseJWT = (token) => {
try {
const value = decodeURIComponent(existingCookie.split('=')[1]);
items = JSON.parse(value);
if (!Array.isArray(items)) items = [];
} catch (e) {
items = [];
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
} catch {
return null;
}
}
setInCart(items.includes(product.id));
}, [product.id]);
const onSetCart = () => {
const existingCookie = document.cookie
.split('; ')
.find(row => row.startsWith('itemsId='));
let items = [];
if (existingCookie) {
try {
const value = decodeURIComponent(existingCookie.split('=')[1]);
items = JSON.parse(value);
if (!Array.isArray(items)) items = [];
} catch (e) {
items = [];
}
}
let updatedItems;
if (items.includes(product.id)) {
updatedItems = items.filter(id => id !== product.id); // remove
setInCart(false);
} else {
updatedItems = [...items, product.id]; // add
setInCart(true);
}
document.cookie = `itemsId=${JSON.stringify(updatedItems)}; path=/; max-age=${7 * 24 * 60 * 60}`;
};
const onCheckout = () => {
const tokenCookie = document.cookie
.split('; ')
.find(row => row.startsWith('token='));
const tokenCookie = document.cookie.split('; ').find(row => row.startsWith('token='));
const token = tokenCookie ? tokenCookie.split('=')[1] : '';
if (!tokenCookie) {
if (!token) {
setPostLoginAction(() => () => onCheckout());
setShowedModal('login');
return;
}
if (product.type == 'product') {
// Jika punya children, tampilkan pilihan
const hasMatchingSubscription = Array.isArray(subscriptions) &&
subscriptions.some(sub =>
sub.product_name?.toLowerCase().includes(product.name.toLowerCase())
);
// Always show children selector first if product has children
if (product.children && product.children.length > 0) {
setShowChildSelector(true);
if (hasMatchingSubscription) {
const matching = subscriptions.filter(sub =>
sub.product_name?.toLowerCase().includes(product.name.toLowerCase())
);
const uniqueByName = Array.from(new Map(matching.map(sub => [sub.product_name, sub])).values());
if (uniqueByName.length > 0) {
setMatchingSubscriptions(uniqueByName);
}
}
return;
}
// Ambil itemsId dari cookie
const itemsCookie = document.cookie
.split('; ')
.find(row => row.startsWith('itemsId='));
// No children, but has subscription match
if (hasMatchingSubscription) {
const matching = subscriptions.filter(sub =>
sub.product_name?.toLowerCase().includes(product.name.toLowerCase())
);
const uniqueByName = Array.from(new Map(matching.map(sub => [sub.product_name, sub])).values());
let items = [];
if (itemsCookie) {
try {
items = JSON.parse(itemsCookie.split('=')[1]);
if (!Array.isArray(items)) items = [];
} catch (e) {
items = [];
if (uniqueByName.length > 0) {
setMatchingSubscriptions(uniqueByName);
setShowSubscriptionSelector(true);
return;
}
}
// Tambahkan product.id jika belum ada
if (!items.includes(product.id)) {
items.push(product.id);
}
const itemsParam = JSON.stringify(items);
// No children, no matching subscription
const itemsParam = JSON.stringify([product.id]);
window.location.href = `http://localhost:3002/?token=${token}&itemsId=${itemsParam}&redirect_uri=http://localhost:3000/products&redirect_failed=http://localhost:3000`;
};
const onConfirmChildren = () => {
const tokenCookie = document.cookie
.split('; ')
.find(row => row.startsWith('token='));
if (matchingSubscriptions.length > 0) {
setShowChildSelector(false);
setShowSubscriptionSelector(true);
return;
}
const tokenCookie = document.cookie.split('; ').find(row => row.startsWith('token='));
const token = tokenCookie ? tokenCookie.split('=')[1] : '';
if (selectedChildIds.length === 0) {
@@ -106,136 +98,189 @@ const onConfirmChildren = () => {
return;
}
// Ambil itemsId dari cookie
const itemsCookie = document.cookie
.split('; ')
.find(row => row.startsWith('itemsId='));
let items = [];
if (itemsCookie) {
try {
items = JSON.parse(itemsCookie.split('=')[1]);
if (!Array.isArray(items)) items = [];
} catch (e) {
items = [];
}
}
// Gabungkan items dari cookie dengan selectedChildIds
const mergedItems = Array.from(new Set([...items, ...selectedChildIds]));
const itemsParam = JSON.stringify(mergedItems);
const itemsParam = selectedChildIds.length > 0 ? JSON.stringify(selectedChildIds) : JSON.stringify([product.id]);
window.location.href = `http://localhost:3002/?token=${token}&itemsId=${itemsParam}&redirect_uri=http://localhost:3000/products&redirect_failed=http://localhost:3000`;
};
const onFinalCheckoutNewProduct = () => {
if (!customName.trim()) {
alert('Nama produk tidak boleh kosong');
return;
}
const tokenCookie = document.cookie.split('; ').find(row => row.startsWith('token='));
const token = tokenCookie ? tokenCookie.split('=')[1] : '';
const itemsParam = selectedChildIds.length > 0 ? JSON.stringify(selectedChildIds) : JSON.stringify([product.id]);
const encodedName = encodeURIComponent(customName.trim());
window.location.href = `http://localhost:3002/?token=${token}&itemsId=${itemsParam}&new_name=${encodedName}&redirect_uri=http://localhost:3000/products&redirect_failed=http://localhost:3000`;
};
const onConfirmSelector = () => {
if (selectedSubscriptionId == null) {
alert('Pilih salah satu langganan.');
return;
}
if (selectedSubscriptionId === product.id) {
setShowNamingInput(true);
} else {
const tokenCookie = document.cookie.split('; ').find(row => row.startsWith('token='));
const token = tokenCookie ? tokenCookie.split('=')[1] : '';
const itemsParam = selectedChildIds.length > 0 ? JSON.stringify(selectedChildIds) : JSON.stringify([product.id]);
const selectedSubscription = matchingSubscriptions.find(
(sub) => sub.id === selectedSubscriptionId
);
const productName = selectedSubscription?.product_name;
const encodedName = encodeURIComponent(productName);
window.location.href = `http://localhost:3002/?token=${token}&itemsId=${itemsParam}&set_name=${encodedName}&redirect_uri=http://localhost:3000/products&redirect_failed=http://localhost:3000`;
}
};
const priceColor = product.price === 0 ? '#059669' : '#2563eb';
return (
<div className={styles.container}>
{/* ✅ Tampilan utama disembunyikan jika sedang memilih child */}
{!showChildSelector && (
{!showChildSelector && !showSubscriptionSelector && !showNamingInput && (
<>
<div
className={styles.image}
style={{ backgroundImage: `url(${product.image})` }}
></div>
<div className={styles.image} style={{ backgroundImage: `url(${product.image})` }}></div>
<div className={styles.headerRow}>
<h2 className={styles.title}>{product.name}</h2>
<div className={styles.price} style={{ color: priceColor }}>
{product.price == null
? 'Pay-As-You-Go'
: `Rp ${parseInt(product.price).toLocaleString('id-ID')}`}
{product.price == null ? 'Pay-As-You-Go' : `Rp ${parseInt(product.price).toLocaleString('id-ID')}`}
</div>
</div>
<p className={styles.description}>{product.description}</p>
<div className={styles.buttonGroup}>
<button
className={`${styles.button} ${styles.addToCartButton}`}
onClick={onSetCart}
onMouseOver={e => (e.target.style.backgroundColor = '#facc15')}
onMouseOut={e => (e.target.style.backgroundColor = '#fbbf24')}
>
<img
src={'/cart-shopping-svgrepo-com.svg'}
alt={inCart ? 'Hapus' : 'Tambah'}
style={{ width: '21px', height: '21px', marginRight: '7px' }}
/>
{inCart ? 'Hapus' : 'Tambah'}
</button>
<button
className={`${styles.button} ${styles.checkoutButton}`}
onClick={onCheckout}
onMouseOver={e => (e.target.style.backgroundColor = '#1d4ed8')}
onMouseOut={e => (e.target.style.backgroundColor = '#2563eb')}
>
<button className={`${styles.button} ${styles.checkoutButton}`} onClick={onCheckout}>
Checkout
</button>
</div>
</>
)}
{/* ✅ UI pemilihan child */}
{showChildSelector && (
<div className={styles.childSelector}>
<h3>Pilih Paket</h3>
{product.children.map(child => (
<label key={child.id} className={styles.childProduct} style={{ display: 'block', marginBottom: '8px' }}>
<label key={child.id} className={styles.childProduct}>
<input
type="checkbox"
value={child.id}
checked={selectedChildIds.includes(child.id)}
onChange={e => {
const checked = e.target.checked;
if (checked) {
setSelectedChildIds(prev => [...prev, child.id]);
} else {
setSelectedChildIds(prev => prev.filter(id => id !== child.id));
}
setSelectedChildIds(prev =>
checked ? [...prev, child.id] : prev.filter(id => id !== child.id)
);
}}
/>
{' '}
{child.name} Rp {parseInt(child.price || 0).toLocaleString('id-ID')}
&nbsp;{child.name} Rp {parseInt(child.price || 0).toLocaleString('id-ID')}
</label>
))}
<p style={{ marginTop: '10px' }}>
<strong>Total Harga:</strong>{' '}
Rp {selectedChildIds
.map(id => {
const found = product.children.find(child => child.id === id);
return found ? found.price || 0 : 0;
})
<p>
<strong>Total Harga:</strong> Rp {selectedChildIds
.map(id => product.children.find(child => child.id === id)?.price || 0)
.reduce((a, b) => a + b, 0)
.toLocaleString('id-ID')}
</p>
<div className={styles.buttonGroup}>
<button
className={`${styles.button} ${styles.cancelButton}`}
onClick={() => {
setShowChildSelector(false);
setSelectedChildIds([]);
}}
>
<button className={styles.button} onClick={() => setShowChildSelector(false)}>
Kembali
</button>
<button
className={`${styles.button} ${styles.confirmButton}`}
onClick={onConfirmChildren}
>
<button className={styles.button} onClick={onConfirmChildren}>
Lanjut ke Checkout
</button>
</div>
</div>
)}
{showSubscriptionSelector && !showNamingInput && (
<div className={styles.childSelector}>
<h5>Perpanjang {product.name}</h5>
{matchingSubscriptions.map(sub => (
<label key={sub.id} className={styles.childProduct}>
<input
type="radio"
name="subscription"
value={sub.id}
checked={selectedSubscriptionId == sub.id}
onChange={() => { setSelectedSubscriptionId(sub.id); setCustomName(sub.product_name) }}
/>
&nbsp;{sub.product_name}
</label>
))}
<h6>Atau buat baru</h6>
<label className={styles.childProduct}>
<input
type="radio"
name="subscription"
checked={selectedSubscriptionId === product.id}
onChange={() => setSelectedSubscriptionId(product.id)}
/>
&nbsp;Buat {product.name} baru
</label>
<div className={styles.buttonGroup}>
<button className={styles.button} onClick={() => setShowSubscriptionSelector(false)}>
Kembali
</button>
<button className={styles.button} onClick={onConfirmSelector}>
Lanjut ke Checkout
</button>
</div>
</div>
)}
{showNamingInput && (
<div className={styles.childSelector}>
<h5>Buat {product.name} Baru</h5>
<input
type="text"
placeholder="Nama produk..."
className={styles.input}
value={customName}
onChange={(e) => setCustomName(e.target.value)}
style={{ width: '100%', padding: '8px', marginBottom: '16px', borderRadius: '10px' }}
/>
{
matchingSubscriptions.some(
(sub) => sub.product_name === `${product.name}@${customName}`
) && (
<p style={{ color: 'red', marginBottom: '10px' }}>
Nama produk sudah digunakan.
</p>
)
}
<div className={styles.buttonGroup}>
<button
className={styles.button}
onClick={() => {
setShowNamingInput(false);
setShowSubscriptionSelector(true);
}}
>
Kembali
</button>
<button
className={styles.button}
onClick={onFinalCheckoutNewProduct}
disabled={
customName.trim() === '' ||
matchingSubscriptions.some(
(sub) => sub.product_name === `${product.name}@${customName}`
)
}
>
Checkout
</button>
</div>
</div>
)}
</div>
);
};

View File

@@ -542,9 +542,88 @@
}
/* Burger icon (hanya muncul di mobile) */
.burger {
display: none;
font-size: 28px;
cursor: pointer;
}
.mobileMenu {
display: none;
}
/* Tampilkan burger dan menu di mobile */
@media (max-width: 600px) {
.nav {
display: none;
}
.burger {
display: block;
}
.authButtons {
display: none; /* sembunyikan tombol login/logout biasa */
}
.mobileMenu {
position: absolute;
top: 60px;
right: 20px;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 12px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.mobileMenu button {
padding: 8px 16px;
border: none;
background-color: #2563eb;
color: white;
border-radius: 6px;
cursor: pointer;
}
.mobileMenu button:hover {
background-color: #1e40af;
}
.mobileMenu .username {
font-weight: bold;
color: #2563eb;
margin-bottom: 4px;
}
}
.loggedInContainer {
display: flex;
align-items: center;
gap: 12px;
}
.username {
color: #2563eb;
font-weight: 600;
}
.logoutButton {
background-color: transparent;
border: 1px solid #2563eb;
color: #2563eb;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.logoutButton:hover {
background-color: #2563eb;
color: white;
}

View File

@@ -3,78 +3,60 @@ import ProductDetailPage from '../ProductDetailPage';
import Login from '../Login';
import styles from '../Styles.module.css';
function parseJwt(token) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
} catch (e) {
return null;
}
}
function getDistinctProductIdsFromJwt(token) {
const payload = parseJwt(token);
if (!payload || !payload.subscriptions || !payload.subscriptions) return [];
const productIds = payload.subscriptions.map(p => p.product_id);
return [...new Set(productIds)];
}
function getLatestEndDatesFromJwt(token) {
const payload = parseJwt(token);
if (!payload || !payload.subscriptions || !payload.subscriptions) return {};
const result = {};
payload.subscriptions.forEach(p => {
if (!p.end_date) return;
const id = p.product_id;
const endDate = new Date(p.end_date);
if (!result[id] || endDate > new Date(result[id])) {
result[id] = p.end_date;
}
});
return result;
}
function getTotalTokenFromJwt(token) {
const payload = parseJwt(token);
if (!payload || !payload.subscriptions || !payload.subscriptions) return {};
const tokenQuantities = {};
payload.subscriptions.forEach(p => {
// Pastikan ada quantity dan unit_type token
if (p.quantity && p.product_id) {
tokenQuantities[p.product_id] = (tokenQuantities[p.product_id] || 0) + p.quantity;
}
});
return tokenQuantities;
}
const CoursePage = () => {
const CoursePage = ({ subscriptions }) => {
const [postLoginAction, setPostLoginAction] = useState(null);
const [selectedProduct, setSelectedProduct] = useState({});
const [hoveredCard, setHoveredCard] = useState(null);
const [showedModal, setShowedModal] = useState(null);
const [products, setProducts] = useState([]);
useEffect(() => {
const match = document.cookie.match(new RegExp('(^| )token=([^;]+)'));
if (match) {
const token = match[2];
if (!subscriptions) return;
const productIds = getDistinctProductIdsFromJwt(token);
const endDates = getLatestEndDatesFromJwt(token);
const tokenQuantitiesFromJwt = getTotalTokenFromJwt(token);
// Step 1: Group subscriptions by product_name
function groupSubscriptionsByProductName(subs) {
const result = {};
subs.forEach(sub => {
const name = sub.product_name;
const productId = sub.product_id;
if (!result[name]) {
result[name] = {
product_id: productId,
product_name: name,
unit_type: sub.unit_type,
end_date: sub.end_date,
quantity: 0,
subscriptions: []
};
}
// Update end_date jika lebih baru
const currentEnd = new Date(result[name].end_date);
const thisEnd = new Date(sub.end_date);
if (thisEnd > currentEnd) {
result[name].end_date = sub.end_date;
}
// Tambahkan quantity jika unit_type adalah 'token'
if (sub.unit_type == 'token') {
result[name].quantity += sub.quantity ?? 0;
} else {
result[name].quantity += 1; // Bisa diabaikan atau tetap hitung 1 per subscription
}
result[name].subscriptions.push(sub);
});
return result;
}
const groupedSubs = groupSubscriptionsByProductName(subscriptions);
// Step 2: Ambil semua unique product_id (tetap diperlukan untuk ambil metadata dari API)
const productIds = [...new Set(subscriptions.map(s => s.product_id))];
// Step 3: Fetch product metadata
fetch('https://bot.kediritechnopark.com/webhook/store-dev/products', {
method: 'POST',
headers: {
@@ -84,57 +66,33 @@ useEffect(() => {
})
.then(res => res.json())
.then(data => {
const parentMap = {};
const childrenMap = {};
const enrichedData = Object.values(groupedSubs).map(group => {
const productData = data.find(p => p.id == group.product_id);
data.forEach(product => {
if (product.sub_product_of) {
const parentId = product.sub_product_of;
if (!childrenMap[parentId]) childrenMap[parentId] = [];
childrenMap[parentId].push(product);
} else {
parentMap[product.id] = {
...product,
quantity: product.quantity || 0,
end_date: endDates[product.id] || null,
children: []
return {
id: group.product_id,
name: group.product_name,
type: productData?.type || 'product',
image: productData?.image || '',
description: productData?.description || '',
price: productData?.price || 0,
currency: productData?.currency || 'IDR',
duration: productData?.duration || {},
sub_product_of: productData?.sub_product_of || null,
is_visible: productData?.is_visible ?? true,
unit_type: productData?.unit_type || group.unit_type,
quantity: group.quantity, // Bisa diganti dengan jumlah token kalau diperlukan
end_date: group.end_date,
children: [] // Kosong, bisa diisi jika ada sub-product
};
}
});
// ...
Object.keys(childrenMap).forEach(parentId => {
const parent = parentMap[parentId];
const children = childrenMap[parentId];
if (parent) {
parent.children = children;
// Pakai quantity dari JWT langsung (tokenQuantitiesFromJwt)
parent.quantity = children.reduce((total, child) => {
return total + (tokenQuantitiesFromJwt[child.id] || 0);
}, 0);
}
});
// ...
// Update quantity untuk produk yang bukan parent dan bukan anak
Object.values(parentMap).forEach(product => {
if (!product.children.length) {
if (product.unit_type === 'token') {
product.quantity = tokenQuantitiesFromJwt[product.id] || 0;
}
}
});
const enrichedData = Object.values(parentMap);
console.log(enrichedData)
setProducts(enrichedData);
console.log(enrichedData);
console.log('Enriched Data:', enrichedData);
})
.catch(err => console.error('Fetch error:', err));
}
}, []);
}, [subscriptions]);
const features = [
@@ -171,19 +129,19 @@ Object.values(parentMap).forEach(product => {
products
.map(product => (
<div
key={product.id}
className={`${styles.courseCard} ${hoveredCard === product.id ? styles.courseCardHover : ''}`}
key={product.name}
className={`${styles.courseCard} ${hoveredCard === product.name ? styles.courseCardHover : ''}`}
onClick={() => {
setSelectedProduct(product);
setShowedModal('product');
}}
onMouseEnter={() => setHoveredCard(product.id)}
onMouseEnter={() => setHoveredCard(product.name)}
onMouseLeave={() => setHoveredCard(null)}
>
<div className={styles.courseImage} style={{ backgroundImage: `url(${product.image})` }}>
{product.price == 0 && (
{/* {product.price == 0 && (
<span className={styles.courseLabel}>Free</span>
)}
)} */}
</div>
<div className={styles.courseContent}>
<h3 className={styles.courseTitle}>{product.name}</h3>