* wip init handle draft class and styling * add draft button * start splitting up listing row into multile files * separate tag links * split location * fix location * separate listing row action buttons * update listing row proptypes to describe listing object * contact via connect to jsx * fix default checked * init draft creation and first publish charge * fix first publish logic and credit charging * handle drafts in dashboard filtering * adjust isDraft bool statement * hide drafts from main listings feed * fix expired and draft bools in listing row * adjust create listing org credits * internally handle drafts * adjust listing row * break down update method * remove unnecessary logic and shorten lines * fix logic again woops * convert input hidden value + submit into one form button submit with corresponding name and value also adds clear message that drafts do not cost credits * handle insufficient org credits on first publish - uses user credits to publish - similar to bump implementation * fix sorting for personal and all orgs * space out some elements * don't spend original users credit for them * add delete buttons and delete_confirm and destroy methods * remove notification removal since listings dont trigger notifications * move draft message and add draft_params to tests * Update classified_listings_controller.rb * update tests * update snapshot * add del tests * add guard preventing free draft publish * fix draft filter * update from master and resolve merge conflict in listing preact * Revert "update from master and resolve merge conflict in listing preact" This reverts commit 0a34fccf334a2ca0902644b486cb26ead6bef664. * update column spec instead of setting and saving * separate two expectations into their own tests * split more tests into single expectations * change to unless bumped_at? instead of if nil * Fix listing draft edge cases and styling * Add title tags to listings pages
534 lines
16 KiB
JavaScript
534 lines
16 KiB
JavaScript
import { h, Component } from 'preact';
|
||
import { SingleListing } from './singleListing';
|
||
|
||
export class Listings extends Component {
|
||
state = {
|
||
listings: [],
|
||
query: '',
|
||
tags: [],
|
||
index: null,
|
||
category: '',
|
||
allCategories: [],
|
||
initialFetch: true,
|
||
currentUserId: null,
|
||
openedListing: null,
|
||
message: '',
|
||
slug: null,
|
||
page: 0,
|
||
showNextPageButt: false,
|
||
};
|
||
|
||
componentWillMount() {
|
||
const params = this.getQueryParams();
|
||
const t = this;
|
||
const algoliaId = document.querySelector("meta[name='algolia-public-id']")
|
||
.content;
|
||
const algoliaKey = document.querySelector("meta[name='algolia-public-key']")
|
||
.content;
|
||
const env = document.querySelector("meta[name='environment']").content;
|
||
const client = algoliasearch(algoliaId, algoliaKey);
|
||
const index = client.initIndex(`ClassifiedListing_${env}`);
|
||
const container = document.getElementById('classifieds-index-container');
|
||
const category = container.dataset.category || '';
|
||
const allCategories = JSON.parse(container.dataset.allcategories || []);
|
||
let tags = [];
|
||
if (params.t) {
|
||
tags = params.t.split(',');
|
||
}
|
||
const query = params.q || '';
|
||
let listings = [];
|
||
if (tags.length === 0 && query === '') {
|
||
listings = JSON.parse(container.dataset.listings);
|
||
}
|
||
let openedListing = null;
|
||
let slug = null;
|
||
if (container.dataset.displayedlisting) {
|
||
openedListing = JSON.parse(container.dataset.displayedlisting);
|
||
slug = openedListing.slug;
|
||
document.body.classList.add('modal-open');
|
||
}
|
||
t.setState({
|
||
query,
|
||
tags,
|
||
index,
|
||
category,
|
||
allCategories,
|
||
listings,
|
||
openedListing,
|
||
slug,
|
||
});
|
||
t.listingSearch(query, tags, category, slug);
|
||
t.setUser();
|
||
|
||
document.body.addEventListener('keydown', t.handleKeyDown);
|
||
|
||
/*
|
||
The width of the columns also changes when the browser is resized
|
||
so we will also call this function on window resize to recalculate
|
||
each grid item's height to avoid content overflow
|
||
*/
|
||
window.addEventListener('resize', resizeAllMasonryItems);
|
||
}
|
||
|
||
componentDidUpdate() {
|
||
this.triggerMasonry();
|
||
}
|
||
|
||
componentWillUnmount() {
|
||
document.body.removeEventListener('keydown', this.handleKeyDown);
|
||
}
|
||
|
||
addTag = (e, tag) => {
|
||
e.preventDefault();
|
||
const { query, tags, category } = this.state;
|
||
const newTags = tags;
|
||
if (newTags.indexOf(tag) === -1) {
|
||
newTags.push(tag);
|
||
}
|
||
this.setState({ tags: newTags, page: 0, listings: [] });
|
||
this.listingSearch(query, newTags, category, null);
|
||
window.scroll(0, 0);
|
||
};
|
||
|
||
removeTag = (e, tag) => {
|
||
e.preventDefault();
|
||
const { query, tags, category } = this.state;
|
||
const newTags = tags;
|
||
const index = newTags.indexOf(tag);
|
||
if (newTags.indexOf(tag) > -1) {
|
||
newTags.splice(index, 1);
|
||
}
|
||
this.setState({ tags: newTags, page: 0, listings: [] });
|
||
this.listingSearch(query, newTags, category, null);
|
||
};
|
||
|
||
selectCategory = (e, cat) => {
|
||
e.preventDefault();
|
||
const { query, tags } = this.state;
|
||
this.setState({ category: cat, page: 0, listings: [] });
|
||
this.listingSearch(query, tags, cat, null);
|
||
};
|
||
|
||
handleKeyDown = e => {
|
||
// Enable Escape key to close an open listing.
|
||
this.handleCloseModal(e);
|
||
};
|
||
|
||
handleCloseModal = e => {
|
||
const { openedListing } = this.state;
|
||
if (
|
||
(openedListing !== null && e.key === 'Escape') ||
|
||
e.target.id === 'single-classified-listing-container__inner' ||
|
||
e.target.id === 'classified-filters' ||
|
||
e.target.id === 'classified-listings-modal-background'
|
||
) {
|
||
const { query, tags, category } = this.state;
|
||
this.setState({ openedListing: null, page: 0 });
|
||
this.setLocation(query, tags, category, null);
|
||
document.body.classList.remove('modal-open');
|
||
}
|
||
};
|
||
|
||
handleOpenModal = (e, listing) => {
|
||
e.preventDefault();
|
||
this.setState({ openedListing: listing });
|
||
window.history.replaceState(
|
||
null,
|
||
null,
|
||
`/listings/${listing.category}/${listing.slug}`,
|
||
);
|
||
this.setLocation(null, null, listing.category, listing.slug);
|
||
document.body.classList.add('modal-open');
|
||
};
|
||
|
||
handleDraftingMessage = e => {
|
||
e.preventDefault();
|
||
this.setState({ message: e.target.value });
|
||
};
|
||
|
||
handleSubmitMessage = e => {
|
||
e.preventDefault();
|
||
const { message, openedListing } = this.state;
|
||
if (this.state.message.replace(/\s/g, '').length === 0) {
|
||
return;
|
||
}
|
||
const formData = new FormData();
|
||
formData.append('user_id', openedListing.user_id);
|
||
formData.append('message', `**re: ${openedListing.title}** ${message}`);
|
||
formData.append('controller', 'chat_channels');
|
||
|
||
const destination = `/connect/@${openedListing.author.username}`;
|
||
const metaTag = document.querySelector("meta[name='csrf-token']");
|
||
window
|
||
.fetch('/chat_channels/create_chat', {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-CSRF-Token': metaTag.getAttribute('content'),
|
||
},
|
||
body: formData,
|
||
credentials: 'same-origin',
|
||
})
|
||
.then(() => {
|
||
window.location.href = destination;
|
||
});
|
||
};
|
||
|
||
handleQuery = e => {
|
||
const { tags, category } = this.state;
|
||
this.setState({ query: e.target.value, page: 0, listings: [] });
|
||
this.listingSearch(e.target.value, tags, category, null);
|
||
};
|
||
|
||
clearQuery = () => {
|
||
const { tags, category } = this.state;
|
||
document.getElementById('listings-search').value = '';
|
||
this.setState({ query: '', page: 0, listings: [] });
|
||
this.listingSearch('', tags, category, null);
|
||
};
|
||
|
||
getQueryParams = () => {
|
||
let qs = document.location.search;
|
||
qs = qs.split('+').join(' ');
|
||
|
||
const params = {};
|
||
let tokens;
|
||
const re = /[?&]?([^=]+)=([^&]*)/g;
|
||
|
||
// eslint-disable-next-line no-cond-assign
|
||
while ((tokens = re.exec(qs))) {
|
||
params[decodeURIComponent(tokens[1])] = decodeURIComponent(tokens[2]);
|
||
}
|
||
|
||
return params;
|
||
};
|
||
|
||
loadNextPage = () => {
|
||
const { query, tags, category, slug, page } = this.state;
|
||
this.setState({ page: page + 1 });
|
||
this.listingSearch(query, tags, category, slug);
|
||
};
|
||
|
||
setUser = () => {
|
||
const t = this;
|
||
setTimeout(function() {
|
||
if (window.currentUser && t.state.currentUserId === null) {
|
||
t.setState({ currentUserId: window.currentUser.id });
|
||
}
|
||
}, 300);
|
||
setTimeout(function() {
|
||
if (window.currentUser && t.state.currentUserId === null) {
|
||
t.setState({ currentUserId: window.currentUser.id });
|
||
}
|
||
}, 1000);
|
||
};
|
||
|
||
triggerMasonry = () => {
|
||
resizeAllMasonryItems();
|
||
setTimeout(function() {
|
||
resizeAllMasonryItems();
|
||
}, 1);
|
||
setTimeout(function() {
|
||
resizeAllMasonryItems();
|
||
}, 3);
|
||
};
|
||
|
||
setLocation = (query, tags, category, slug) => {
|
||
let newLocation = '';
|
||
if (slug) {
|
||
newLocation = `/listings/${category}/${slug}`;
|
||
} else if (query.length > 0 && tags.length > 0) {
|
||
newLocation = `/listings/${category}?q=${query}&t=${tags}`;
|
||
} else if (query.length > 0) {
|
||
newLocation = `/listings/${category}?q=${query}`;
|
||
} else if (tags.length > 0) {
|
||
newLocation = `/listings/${category}?t=${tags}`;
|
||
} else if (category.length > 0) {
|
||
newLocation = `/listings/${category}`;
|
||
} else {
|
||
newLocation = '/listings';
|
||
}
|
||
window.history.replaceState(null, null, newLocation);
|
||
};
|
||
|
||
listingSearch(query, tags, category, slug) {
|
||
const t = this;
|
||
const { index, page, listings } = t.state;
|
||
const filterObject = { tagFilters: tags, hitsPerPage: 75, page };
|
||
if (category.length > 0) {
|
||
filterObject.filters = `category:${category}`;
|
||
}
|
||
index.search(query, filterObject).then(function searchDone(content) {
|
||
const fullListings = listings;
|
||
content.hits.forEach(listing => {
|
||
if (listing.bumped_at) {
|
||
if (!listings.map(l => l.id).includes(listing.id)) {
|
||
fullListings.push(listing);
|
||
}
|
||
}
|
||
});
|
||
t.setState({
|
||
listings: fullListings,
|
||
initialFetch: false,
|
||
showNextPageButt: content.hits.length === 75,
|
||
});
|
||
});
|
||
this.setLocation(query, tags, category, slug);
|
||
}
|
||
|
||
render() {
|
||
const {
|
||
listings,
|
||
query,
|
||
tags,
|
||
category,
|
||
allCategories,
|
||
currentUserId,
|
||
openedListing,
|
||
showNextPageButt,
|
||
initialFetch,
|
||
} = this.state;
|
||
const allListings = listings.map(listing => (
|
||
<SingleListing
|
||
onAddTag={this.addTag}
|
||
onChangeCategory={this.selectCategory}
|
||
listing={listing}
|
||
currentUserId={currentUserId}
|
||
onOpenModal={this.handleOpenModal}
|
||
isOpen={false}
|
||
/>
|
||
));
|
||
const selectedTags = tags.map(tag => (
|
||
<span className="classified-tag">
|
||
<a
|
||
href="/listings?tags="
|
||
className="tag-name"
|
||
onClick={e => this.removeTag(e, tag)}
|
||
data-no-instant
|
||
>
|
||
<span>{tag}</span>
|
||
<span
|
||
className="tag-close"
|
||
onClick={e => this.removeTag(e, tag)}
|
||
data-no-instant
|
||
>
|
||
×
|
||
</span>
|
||
</a>
|
||
</span>
|
||
));
|
||
const categoryLinks = allCategories.map(cat => (
|
||
<a
|
||
href={`/listings/${cat.slug}`}
|
||
className={cat.slug === category ? 'selected' : ''}
|
||
onClick={e => this.selectCategory(e, cat.slug)}
|
||
data-no-instant
|
||
>
|
||
{cat.name}
|
||
</a>
|
||
));
|
||
let nextPageButt = '';
|
||
if (showNextPageButt) {
|
||
nextPageButt = (
|
||
<div className="classifieds-load-more-button">
|
||
<button onClick={e => this.loadNextPage(e)} type="button">
|
||
Load More Listings
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
const clearQueryButton =
|
||
query.length > 0 ? (
|
||
<button
|
||
type="button"
|
||
className="classified-search-clear"
|
||
onClick={this.clearQuery}
|
||
>
|
||
×
|
||
</button>
|
||
) : (
|
||
''
|
||
);
|
||
let modal = '';
|
||
let modalBg = '';
|
||
let messageModal = '';
|
||
if (openedListing) {
|
||
modalBg = (
|
||
<div
|
||
className="classified-listings-modal-background"
|
||
onClick={this.handleCloseModal}
|
||
role="presentation"
|
||
id="classified-listings-modal-background"
|
||
/>
|
||
);
|
||
if (
|
||
openedListing.contact_via_connect &&
|
||
openedListing.user_id !== currentUserId
|
||
) {
|
||
messageModal = (
|
||
<form
|
||
id="listings-message-form"
|
||
className="listings-contact-via-connect"
|
||
onSubmit={this.handleSubmitMessage}
|
||
>
|
||
<p>
|
||
<b>Contact {openedListing.author.name} via DEV Connect</b>
|
||
</p>
|
||
<textarea
|
||
value={this.state.message}
|
||
onChange={this.handleDraftingMessage}
|
||
id="new-message"
|
||
rows="4"
|
||
cols="70"
|
||
placeholder="Enter your message here..."
|
||
/>
|
||
<button type="submit" value="Submit" className="submit-button cta">
|
||
SEND
|
||
</button>
|
||
<p>
|
||
<em>
|
||
Message must be relevant and on-topic with the listing. All
|
||
private interactions <b>must</b> abide by the{' '}
|
||
<a href="/code-of-conduct">code of conduct</a>
|
||
</em>
|
||
</p>
|
||
</form>
|
||
);
|
||
} else if (openedListing.contact_via_connect) {
|
||
messageModal = (
|
||
<form
|
||
id="listings-message-form"
|
||
className="listings-contact-via-connect"
|
||
>
|
||
<p>
|
||
This is your active listing. Any member can contact you via this
|
||
form.
|
||
</p>
|
||
<textarea
|
||
value={this.state.message}
|
||
onChange={this.handleDraftingMessage}
|
||
id="new-message"
|
||
rows="4"
|
||
cols="70"
|
||
placeholder="Enter your message here..."
|
||
/>
|
||
<button type="submit" value="Submit" className="submit-button cta">
|
||
SEND
|
||
</button>
|
||
<p>
|
||
<em>
|
||
All private interactions <b>must</b> abide by the{' '}
|
||
<a href="/code-of-conduct">code of conduct</a>
|
||
</em>
|
||
</p>
|
||
</form>
|
||
);
|
||
}
|
||
modal = (
|
||
<div className="single-classified-listing-container">
|
||
<div
|
||
id="single-classified-listing-container__inner"
|
||
className="single-classified-listing-container__inner"
|
||
onClick={this.handleCloseModal}
|
||
>
|
||
<SingleListing
|
||
onAddTag={this.addTag}
|
||
onChangeCategory={this.selectCategory}
|
||
listing={openedListing}
|
||
currentUserId={currentUserId}
|
||
onOpenModal={this.handleOpenModal}
|
||
isOpen
|
||
/>
|
||
{messageModal}
|
||
<a
|
||
href="/about-listings"
|
||
className="single-classified-listing-info-link"
|
||
>
|
||
About DEV Listings
|
||
</a>
|
||
<div className="single-classified-listing-container__spacer" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (initialFetch) {
|
||
this.triggerMasonry();
|
||
}
|
||
return (
|
||
<div className="listings__container">
|
||
{modalBg}
|
||
<div className="classified-filters" id="classified-filters">
|
||
<div className="classified-filters-categories">
|
||
<a
|
||
href="/listings"
|
||
className={category === '' ? 'selected' : ''}
|
||
onClick={e => this.selectCategory(e, '')}
|
||
data-no-instant
|
||
>
|
||
all
|
||
</a>
|
||
{categoryLinks}
|
||
<a href="/listings/new" className="classified-create-link">
|
||
Create a Listing
|
||
</a>
|
||
<a href="/listings/dashboard" className="classified-create-link">
|
||
Manage Listings
|
||
</a>
|
||
</div>
|
||
<div className="classified-filters-tags" id="classified-filters-tags">
|
||
<input
|
||
type="text"
|
||
placeholder="search"
|
||
id="listings-search"
|
||
autoComplete="off"
|
||
defaultValue={query}
|
||
onKeyUp={e => this.handleQuery(e)}
|
||
/>
|
||
{clearQueryButton}
|
||
{selectedTags}
|
||
</div>
|
||
</div>
|
||
<div className="classifieds-columns" id="classified-listings-results">
|
||
{allListings}
|
||
</div>
|
||
{nextPageButt}
|
||
{modal}
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
function resizeMasonryItem(item) {
|
||
/* Get the grid object, its row-gap, and the size of its implicit rows */
|
||
const grid = document.getElementsByClassName('classifieds-columns')[0];
|
||
const rowGap = parseInt(
|
||
window.getComputedStyle(grid).getPropertyValue('grid-row-gap'),
|
||
);
|
||
const rowHeight = parseInt(
|
||
window.getComputedStyle(grid).getPropertyValue('grid-auto-rows'),
|
||
);
|
||
|
||
const rowSpan = Math.ceil(
|
||
(item.querySelector('.listing-content').getBoundingClientRect().height +
|
||
rowGap) /
|
||
(rowHeight + rowGap),
|
||
);
|
||
|
||
/* Set the spanning as calculated above (S) */
|
||
// eslint-disable-next-line no-param-reassign
|
||
item.style.gridRowEnd = `span ${rowSpan}`;
|
||
}
|
||
function resizeAllMasonryItems() {
|
||
// Get all item class objects in one list
|
||
const allItems = document.getElementsByClassName('single-classified-listing');
|
||
|
||
/*
|
||
* Loop through the above list and execute the spanning function to
|
||
* each list-item (i.e. each masonry item)
|
||
*/
|
||
// eslint-disable-next-line vars-on-top
|
||
for (let i = 0; i < allItems.length; i++) {
|
||
resizeMasonryItem(allItems[i]);
|
||
}
|
||
}
|
||
|
||
Listings.displayName = 'Classified Listings';
|