feat(MainModal): enhance deep linking and navigation handling for collections and products

fix(Tabs): improve sidebar visibility logic and margin adjustment
feat(ModalTopBar): update breadcrumb logic for collection and product views
feat(Added): add button to navigate to discover page with deep linking
fix(Browse): refine error handling and update hash for collection navigation
This commit is contained in:
alexsparkes
2025-10-31 11:15:20 +00:00
parent 389590a04d
commit 94c92f7216
6 changed files with 309 additions and 214 deletions

View File

@@ -5,7 +5,7 @@ import './scss/index.scss';
import ModalLoader from './components/ModalLoader';
import ModalTopBar from './components/ModalTopBar';
import { TAB_TYPES } from './constants/tabConfig';
import { updateHash, onHashChange } from 'utils/deepLinking';
import { updateHash, parseDeepLink } from 'utils/deepLinking';
const Settings = lazy(() => import('../../../features/misc/views/Settings'));
const Library = lazy(() => import('../../../features/misc/views/Library'));
@@ -25,58 +25,88 @@ function MainModal({ modalClose, deepLinkData }) {
const [currentSection, setCurrentSection] = useState('');
const [productView, setProductView] = useState(null);
const [resetDiscoverToAll, setResetDiscoverToAll] = useState(false);
const [navigationHistory, setNavigationHistory] = useState([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [navigationTrigger, setNavigationTrigger] = useState(null);
const [skipNextHistoryAdd, setSkipNextHistoryAdd] = useState(0);
// Clear product view when changing tabs
useEffect(() => {
setProductView(null);
}, [currentTab]);
// Clear hash when modal closes
useEffect(() => {
// Listen for hash changes while modal is open
const cleanup = onHashChange((linkData) => {
if (linkData && linkData.tab !== currentTab) {
setCurrentTab(linkData.tab);
return () => {
// When modal unmounts, clear the hash
if (window.location.hash) {
window.history.replaceState(null, null, window.location.pathname);
}
});
};
}, []);
return cleanup;
useEffect(() => {
// Listen for browser back/forward navigation via popstate
const handlePopState = () => {
const linkData = window.location.hash ? parseDeepLink(window.location.hash) : null;
if (linkData) {
// Update tab if different
if (linkData.tab && linkData.tab !== currentTab) {
setCurrentTab(linkData.tab);
}
// Handle product and collection navigation
if (linkData.itemId && linkData.collection && linkData.fromCollection) {
// Product viewed from within a collection
// First set collection state, then navigate to product
setNavigationTrigger({
type: 'collection',
data: linkData.collection,
timestamp: Date.now(),
});
// Small delay to ensure collection state is set before navigating to product
setTimeout(() => {
setNavigationTrigger({
type: 'product',
data: {
id: linkData.itemId,
type: linkData.category,
},
timestamp: Date.now(),
});
}, 100);
} else if (linkData.itemId) {
// Product navigation (standalone)
setNavigationTrigger({
type: 'product',
data: {
id: linkData.itemId,
type: linkData.category,
},
timestamp: Date.now(),
});
} else if (linkData.collection) {
// Collection page navigation
setNavigationTrigger({
type: 'collection',
data: linkData.collection,
timestamp: Date.now(),
});
} else {
// Back to main view (clear collection state)
setProductView(null);
setNavigationTrigger({
type: 'main',
data: { clearCollection: true },
timestamp: Date.now(),
});
}
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [currentTab]);
const addToHistory = (state) => {
// Check if this state is different from the current one
const currentState = navigationHistory[historyIndex];
const isDifferent = !currentState ||
currentState.tab !== state.tab ||
currentState.section !== state.section ||
JSON.stringify(currentState.product) !== JSON.stringify(state.product);
if (isDifferent) {
const newHistory = navigationHistory.slice(0, historyIndex + 1);
newHistory.push(state);
setNavigationHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
console.log('Added to history:', state, 'New history:', newHistory);
} else {
console.log('Skipping duplicate history entry:', state);
}
};
const handleChangeTab = (newTab) => {
// Only add to history if not navigating via history
if (skipNextHistoryAdd === 0) {
addToHistory({
tab: newTab,
section: '',
product: null,
});
} else {
setSkipNextHistoryAdd(0); // Reset skip counter
}
setCurrentTab(newTab);
// Update URL hash when tab changes
if (newTab === TAB_TYPES.DISCOVER) {
@@ -112,23 +142,8 @@ function MainModal({ modalClose, deepLinkData }) {
const handleProductView = (product) => {
setProductView(product);
// Add to navigation history only if not skipping
// Store only essential product info, not full object
if (product && skipNextHistoryAdd === 0) {
addToHistory({
tab: currentTab,
section: currentSection,
product: {
type: product.type,
name: product.name,
id: product.id,
},
});
} else if (skipNextHistoryAdd > 0) {
console.log('Skipping product view history add');
setSkipNextHistoryAdd(0); // Reset after use
}
// URL hash is already updated by child components (Browse.jsx)
// Browser history automatically tracks hash changes
};
const handleResetDiscoverToAll = () => {
@@ -137,115 +152,17 @@ function MainModal({ modalClose, deepLinkData }) {
};
const handleBack = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
const previousState = navigationHistory[newIndex];
console.log('Going back to:', previousState);
setHistoryIndex(newIndex);
// Set skip flag BEFORE changing any state
setSkipNextHistoryAdd(1);
// Change tab if different
if (previousState.tab !== currentTab) {
setCurrentTab(previousState.tab);
}
setCurrentSection(previousState.section);
setProductView(previousState.product);
// Trigger navigation in child components
if (previousState.product) {
// Viewing a product
setNavigationTrigger({
type: 'product',
data: previousState.product,
timestamp: Date.now(),
});
updateHash(`#${previousState.tab}/${previousState.product.type}/${previousState.product.id}`);
} else {
// Viewing main view
setNavigationTrigger({
type: 'main',
data: null,
timestamp: Date.now(),
});
if (previousState.tab === TAB_TYPES.DISCOVER) {
const sectionMap = {
[variables.getMessage('modals.main.marketplace.all')]: 'all',
[variables.getMessage('modals.main.marketplace.photo_packs')]: 'photo_packs',
[variables.getMessage('modals.main.marketplace.quote_packs')]: 'quote_packs',
[variables.getMessage('modals.main.marketplace.preset_settings')]: 'preset_settings',
[variables.getMessage('modals.main.marketplace.collections')]: 'collections',
};
const sectionKey = sectionMap[previousState.section] || 'all';
updateHash(`#${previousState.tab}/${sectionKey}`);
} else if (previousState.tab === TAB_TYPES.LIBRARY) {
updateHash(`#${previousState.tab}/added`);
} else {
updateHash(`#${previousState.tab}`);
}
}
}
window.history.back();
};
const handleForward = () => {
if (historyIndex < navigationHistory.length - 1) {
const newIndex = historyIndex + 1;
const nextState = navigationHistory[newIndex];
console.log('Going forward to:', nextState);
setHistoryIndex(newIndex);
// Set skip flag BEFORE changing any state
setSkipNextHistoryAdd(1);
// Change tab if different
if (nextState.tab !== currentTab) {
setCurrentTab(nextState.tab);
}
setCurrentSection(nextState.section);
setProductView(nextState.product);
// Trigger navigation in child components
if (nextState.product) {
setNavigationTrigger({
type: 'product',
data: nextState.product,
timestamp: Date.now(),
});
updateHash(`#${nextState.tab}/${nextState.product.type}/${nextState.product.id}`);
} else {
setNavigationTrigger({
type: 'main',
data: null,
timestamp: Date.now(),
});
if (nextState.tab === TAB_TYPES.DISCOVER) {
const sectionMap = {
[variables.getMessage('modals.main.marketplace.all')]: 'all',
[variables.getMessage('modals.main.marketplace.photo_packs')]: 'photo_packs',
[variables.getMessage('modals.main.marketplace.quote_packs')]: 'quote_packs',
[variables.getMessage('modals.main.marketplace.preset_settings')]: 'preset_settings',
[variables.getMessage('modals.main.marketplace.collections')]: 'collections',
};
const sectionKey = sectionMap[nextState.section] || 'all';
updateHash(`#${nextState.tab}/${sectionKey}`);
} else if (nextState.tab === TAB_TYPES.LIBRARY) {
updateHash(`#${nextState.tab}/added`);
} else {
updateHash(`#${nextState.tab}`);
}
}
}
window.history.forward();
};
const canGoBack = historyIndex > 0;
const canGoForward = historyIndex < navigationHistory.length - 1;
// Browser manages history state, so we always show buttons enabled
// Browser will handle whether there's actually history to go back/forward
const canGoBack = true;
const canGoForward = true;
const TabComponent = TAB_COMPONENTS[currentTab] || Settings;
@@ -264,6 +181,7 @@ function MainModal({ modalClose, deepLinkData }) {
/>
<Suspense fallback={<ModalLoader />}>
<TabComponent
key={currentTab}
changeTab={handleChangeTab}
deepLinkData={deepLinkData}
currentTab={currentTab}

View File

@@ -52,7 +52,7 @@ const Tabs = ({ children, navbar = false, currentTab: activeTab, onSectionChange
return (
<div style={{ display: 'flex', width: '100%', minHeight: '100%' }}>
{showSidebar && (
{showSidebar ? (
<div className="modalSidebar">
{children.map((tab, index) => (
<Tab
@@ -65,8 +65,8 @@ const Tabs = ({ children, navbar = false, currentTab: activeTab, onSectionChange
))}
<ReminderInfo isVisible={showReminder} onHide={handleHideReminder} />
</div>
)}
<div className="modalTabContent">
) : null}
<div className="modalTabContent" style={{ marginLeft: showSidebar ? '1rem' : '0' }}>
{children.map((tab, index) => {
if (tab.props.label !== currentTab) {
return null;

View File

@@ -43,18 +43,43 @@ function ModalTopBar({
});
if (productView) {
// Show: Discover > Category > Product
const categoryKey = MARKETPLACE_TYPE_TO_KEY[productView.type];
if (categoryKey) {
console.log('ModalTopBar productView:', productView);
console.log('fromCollection:', productView.fromCollection, 'isCollection:', productView.isCollection, 'collectionTitle:', productView.collectionTitle);
// If viewing a collection page itself (not a product within it)
if (productView.isCollection) {
// Show: Discover > Collection Name
breadcrumbPath.push({
label: variables.getMessage(categoryKey),
onClick: productView.onBack || null,
label: productView.collectionTitle || productView.name,
onClick: null, // Current page - not clickable
});
} else {
// Viewing a product
// Show: Discover > Collection/Category > Product
if (productView.fromCollection && productView.collectionTitle) {
console.log('Showing collection breadcrumb:', productView.collectionTitle);
// If from a collection, show collection name
breadcrumbPath.push({
label: productView.collectionTitle,
onClick: productView.onBack || null,
});
} else {
console.log('Showing category breadcrumb');
// Otherwise show category
const categoryKey = MARKETPLACE_TYPE_TO_KEY[productView.type];
if (categoryKey) {
breadcrumbPath.push({
label: variables.getMessage(categoryKey),
onClick: productView.onBack || null,
});
}
}
// Add product name as final breadcrumb
breadcrumbPath.push({
label: productView.name,
onClick: null, // Current item - not clickable
});
}
breadcrumbPath.push({
label: productView.name,
onClick: null, // Current item - not clickable
});
} else if (currentSection) {
// Show: Tab > Section
breadcrumbPath.push({

View File

@@ -1,6 +1,6 @@
import variables from 'config/variables';
import { memo, useState, useEffect, useCallback } from 'react';
import { MdUpdate, MdOutlineExtensionOff, MdSendTimeExtension } from 'react-icons/md';
import { MdUpdate, MdOutlineExtensionOff, MdSendTimeExtension, MdExplore } from 'react-icons/md';
import { toast } from 'react-toastify';
import Modal from 'react-modal';
@@ -12,6 +12,7 @@ import { Header, CustomActions } from 'components/Layout/Settings';
import { Button } from 'components/Elements';
import { install, uninstall, urlParser } from 'utils/marketplace';
import { updateHash } from 'utils/deepLinking';
const Added = memo(() => {
const [installed, setInstalled] = useState(JSON.parse(localStorage.getItem('installed')));
@@ -203,6 +204,13 @@ const Added = memo(() => {
</>
);
const goToDiscover = useCallback(() => {
updateHash('#discover/all');
// Trigger a popstate event to update the UI
const event = new window.Event('popstate');
window.dispatchEvent(event);
}, []);
if (installed.length === 0) {
return (
<>
@@ -219,6 +227,12 @@ const Added = memo(() => {
<span className="subtitle">
{variables.getMessage('modals.main.addons.empty.description')}
</span>
<Button
type="collection"
onClick={goToDiscover}
icon={<MdExplore />}
label="Get Some"
/>
</div>
</div>
</>

View File

@@ -12,7 +12,6 @@ import {
import ItemPage from './ItemPage';
import Items from '../components/Items/Items';
import { Header } from 'components/Layout/Settings';
import { Button } from 'components/Elements';
import { install, urlParser, uninstall } from 'utils/marketplace';
@@ -99,20 +98,28 @@ class Marketplace extends PureComponent {
// Error caught but only used for flow control
if (this.controller.signal.aborted === false) {
console.error('Failed to fetch item:', error);
return toast(variables.getMessage('toasts.error'));
toast(variables.getMessage('toasts.error'));
}
return;
}
if (this.controller.signal.aborted === true) {
return;
}
// Verify we have valid data before proceeding
if (!info || !info.data) {
console.error('Invalid item data received:', info);
toast(variables.getMessage('toasts.error'));
return;
}
// check if already installed
let button = this.buttons.install;
let addonInstalled = false;
let addonInstalledVersion;
const installed = JSON.parse(localStorage.getItem('installed'));
const installed = JSON.parse(localStorage.getItem('installed')) || [];
if (installed.some((item) => item.name === info.data.name)) {
button = this.buttons.uninstall;
@@ -146,24 +153,49 @@ class Marketplace extends PureComponent {
// Update URL hash with item ID for deep linking
if (info.data?.id) {
updateHash(`#discover/${info.data.type}/${info.data.id}`);
// If viewing from a collection, include collection in URL
if (this.state.collection && this.state.collectionName) {
updateHash(`#discover/collection/${this.state.collectionName}/${info.data.id}`);
} else {
updateHash(`#discover/${info.data.type}/${info.data.id}`);
}
}
// Notify parent about product view for breadcrumbs
if (this.props.onProductView) {
this.props.onProductView({
const productViewData = {
id: info.data.id,
type: info.data.type,
name: info.data.display_name || info.data.name,
fromCollection: this.state.collection,
collectionName: this.state.collectionName,
collectionTitle: this.state.collectionTitle,
collectionDescription: this.state.collectionDescription,
collectionImg: this.state.collectionImg,
onBack: () => this.toggle('main'),
onBackToAll: () => {
this.toggle('main');
updateHash('#discover/all');
if (this.props.onResetToAll) {
this.props.onResetToAll();
}
// Clear collection state before toggling to main
this.setState(
{
collection: false,
collectionName: null,
collectionTitle: null,
collectionDescription: null,
collectionImg: null,
},
() => {
// Call after state is updated
updateHash('#discover/all');
this.toggle('main');
if (this.props.onResetToAll) {
this.props.onResetToAll();
}
},
);
},
});
};
console.log('Product view data being sent to parent:', productViewData);
this.props.onProductView(productViewData);
}
document.querySelector('#modal').scrollTop = 0;
@@ -177,21 +209,82 @@ class Marketplace extends PureComponent {
})
).json();
this.setState({
const collectionState = {
items: collection.data.items,
collectionName: data, // Store the collection name/slug for navigation
collectionTitle: collection.data.display_name,
collectionDescription: collection.data.description,
collectionImg: collection.data.img,
collection: true,
done: true,
});
};
console.log('Setting collection state:', collectionState);
this.setState(collectionState);
// Update hash for collection deep linking
updateHash(`#discover/collection/${data}`);
// Notify parent about collection view for breadcrumbs
if (this.props.onProductView) {
const collectionViewData = {
id: data,
type: 'collection',
name: collection.data.display_name,
isCollection: true,
fromCollection: false,
collectionName: data,
collectionTitle: collection.data.display_name,
onBack: () => {
this.setState(
{
collection: false,
collectionName: null,
collectionTitle: null,
collectionDescription: null,
collectionImg: null,
},
() => {
// Call toggle after state is updated
updateHash('#discover/all');
this.toggle('main');
},
);
},
onBackToAll: () => {
// Clear collection state and return to main view
this.setState(
{
collection: false,
collectionName: null,
collectionTitle: null,
collectionDescription: null,
collectionImg: null,
},
() => {
// Call toggle after state is updated
updateHash('#discover/all');
this.toggle('main');
if (this.props.onResetToAll) {
this.props.onResetToAll();
}
},
);
},
};
console.log('Collection view data being sent to parent:', collectionViewData);
this.props.onProductView(collectionViewData);
}
} else {
this.setState({ item: {}, relatedItems: [] });
// Clear hash when returning to main view
updateHash(`#discover/${this.props.type}`);
// Update hash based on current view state
if (this.state.collection && this.state.collectionName) {
// If returning to a collection view, preserve collection hash
updateHash(`#discover/collection/${this.state.collectionName}`);
} else {
// Clear hash when returning to main view
updateHash(`#discover/${this.props.type}`);
}
// Clear product view for breadcrumbs
if (this.props.onProductView) {
@@ -325,7 +418,15 @@ class Marketplace extends PureComponent {
}
returnToMain() {
this.setState({ items: this.state.oldItems, collection: false });
this.setState({
items: this.state.oldItems,
collection: false,
collectionName: null,
collectionTitle: null,
collectionDescription: null,
collectionImg: null,
});
updateHash(`#discover/${this.props.type}`);
}
componentDidMount() {
@@ -363,9 +464,28 @@ class Marketplace extends PureComponent {
if (type === 'product' && data) {
// Navigate to product
this.toggle('item', { id: data.id, type: data.type });
} else if (type === 'collection' && data) {
// Navigate to collection
this.toggle('collection', data);
} else if (type === 'main' || type === 'section') {
// Navigate to main view (browse/listing page)
this.toggle('main');
// Check if we need to clear collection state first
if (data && data.clearCollection && this.state.collection) {
this.setState(
{
collection: false,
collectionName: null,
collectionTitle: null,
collectionDescription: null,
collectionImg: null,
},
() => {
this.toggle('main');
},
);
} else {
this.toggle('main');
}
}
}
}
@@ -445,12 +565,6 @@ class Marketplace extends PureComponent {
<>
{this.state.collection === true ? (
<>
<Header
title={variables.getMessage('modals.main.navbar.marketplace')}
secondaryTitle={this.state.collectionTitle}
report={false}
goBack={() => this.returnToMain()}
/>
<div
className="collectionPage"
style={{

View File

@@ -10,14 +10,16 @@ import { TAB_TYPES } from '../components/Elements/MainModal/constants/tabConfig'
/**
* Parse hash from URL
* Examples (NEW API v2 format):
* #marketplace/f41219846700 -> { tab: 'marketplace', itemId: 'f41219846700' }
* #marketplace/preset_settings/f41219846700 -> { tab: 'marketplace', category: 'preset_settings', itemId: 'f41219846700' }
* #marketplace/collection/featured -> { tab: 'marketplace', collection: 'featured' }
* #discover/f41219846700 -> { tab: 'discover', itemId: 'f41219846700' }
* #discover/preset_settings/f41219846700 -> { tab: 'discover', category: 'preset_settings', itemId: 'f41219846700' }
* #discover/collection/featured -> { tab: 'discover', collection: 'featured' }
* #discover/collection/featured/f41219846700 -> { tab: 'discover', collection: 'featured', itemId: 'f41219846700' }
* #marketplace/74ef53ceed0b -> { tab: 'discover', itemId: '74ef53ceed0b' } (marketplace is aliased to discover)
* #settings/appearance -> { tab: 'settings', section: 'appearance' }
* #addons -> { tab: 'addons' }
*
* Legacy format (still supported):
* #marketplace/quote_packs/digital-stoicism -> converted to item lookup
* #discover/quote_packs/digital-stoicism -> converted to item lookup
*/
export const parseDeepLink = (hash = window.location.hash) => {
if (!hash || hash === '#') {
@@ -34,25 +36,36 @@ export const parseDeepLink = (hash = window.location.hash) => {
itemId: parts[2],
};
// Handle marketplace as an alias for discover (for backward compatibility with external URLs)
if (result.tab === 'marketplace') {
result.tab = 'discover';
}
// Validate tab
const validTabs = Object.values(TAB_TYPES);
if (!validTabs.includes(result.tab)) {
return null;
}
// Handle marketplace-specific parsing
if (result.tab === 'marketplace') {
// Handle discover-specific parsing (marketplace URLs are aliased to discover)
if (result.tab === 'discover') {
// Check if it's a collection
if (result.section === 'collection') {
result.collection = result.itemId;
result.itemId = null;
// Check if there's a 4th part (item ID within collection)
if (parts[3]) {
result.itemId = parts[3];
result.fromCollection = true;
} else {
result.itemId = null;
}
}
// Check if section is a category (preset_settings, photo_packs, quote_packs)
else if (['preset_settings', 'photo_packs', 'quote_packs', 'all'].includes(result.section)) {
result.category = result.section;
// Third part is the item ID (already in result.itemId)
}
// If only one part after marketplace, assume it's an item ID
// If only one part after tab, assume it's an item ID
else if (result.section && !result.itemId) {
result.itemId = result.section;
result.section = null;
@@ -64,20 +77,25 @@ export const parseDeepLink = (hash = window.location.hash) => {
/**
* Create a deep link hash
* @param {string} tab - The main tab (settings, marketplace, addons)
* @param {string} tab - The main tab (settings, discover, addons)
* @param {object} options - Additional options
* @param {string} options.itemId - Item ID for marketplace items (v2 format)
* @param {string} options.category - Category for marketplace items (optional)
* @param {string} options.collection - Collection name for marketplace
* @param {string} options.itemId - Item ID for discover/marketplace items (v2 format)
* @param {string} options.category - Category for discover/marketplace items (optional)
* @param {string} options.collection - Collection name for discover/marketplace
* @param {boolean} options.fromCollection - If item is being viewed from within a collection
* @param {string} options.section - Section within the tab
* @returns {string} Hash string
*/
export const createDeepLink = (tab, options = {}) => {
let hash = `#${tab}`;
if (tab === 'marketplace') {
// Collection link
if (options.collection) {
if (tab === 'discover') {
// Collection with item (item viewed from within a collection)
if (options.collection && options.itemId && options.fromCollection) {
hash += `/collection/${options.collection}/${options.itemId}`;
}
// Collection link (just the collection page)
else if (options.collection) {
hash += `/collection/${options.collection}`;
}
// Item with category
@@ -101,10 +119,16 @@ export const createDeepLink = (tab, options = {}) => {
/**
* Update URL hash without triggering page reload
* @param {string} hash - The hash to set
* @param {boolean} pushToHistory - If true, adds to browser history (default: true)
*/
export const updateHash = (hash) => {
export const updateHash = (hash, pushToHistory = true) => {
if (window.history.pushState) {
window.history.pushState(null, null, hash);
if (pushToHistory) {
window.history.pushState(null, null, hash);
} else {
window.history.replaceState(null, null, hash);
}
} else {
window.location.hash = hash;
}