-
-
-
-
-
- Results for
-
- { this.props.query }
-
-
- {this.state.totalResults?.getValue() > 0 ?
-
-
{this.state.totalResults.asString()}
-
Results
+
+
+
+
+
+
+ {this.props.searchTopMsg}
+
+ {this.props.query}
+
+
+ {this.props.totalResults?.getValue() > 0 ?
+
+ {this.props.totalResults.asString()}
+ Results
+
+ : null}
- : null }
+ {sortComponent}
+ {searchResultList}
-
this.setState({totalResults: n})}
- openMobileFilters={() => this.setState({mobileFiltersOpen: true})}
- />
-
-
- {(Sefaria.multiPanel && !this.props.compare) || this.state.mobileFiltersOpen ?
-
- {this.state.totalResults?.getValue() > 0 ?
-
this.setState({mobileFiltersOpen: false})}
- compare={this.props.compare}
- type={this.props.type} />
- : null }
+ {(Sefaria.multiPanel && !this.props.compare) || this.state.mobileFiltersOpen ?
+
+ {this.props.totalResults?.getValue() > 0 ?
+ this.setState({mobileFiltersOpen: false})}
+ compare={this.props.compare}
+ type={this.props.type}/>
+ : null}
+
+ : null}
- : null }
+ {this.props.panelsOpen === 1 ?
: null}
- { this.props.panelsOpen === 1 ?
: null }
-
);
}
}
+
SearchPage.propTypes = {
- interfaceLang: PropTypes.oneOf(["english", "hebrew"]),
query: PropTypes.string,
type: PropTypes.oneOf(["text", "sheet"]),
searchState: PropTypes.object,
@@ -103,6 +123,11 @@ SearchPage.propTypes = {
updateAppliedOptionField: PropTypes.func,
updateAppliedOptionSort: PropTypes.func,
registerAvailableFilters: PropTypes.func,
+ loadNextPage: PropTypes.func,
+ moreToLoad: PropTypes.bool,
+ topics: PropTypes.array,
+ totalResults: PropTypes.object,
+ sortTypeArray: PropTypes.array,
};
diff --git a/static/js/SearchResultList.jsx b/static/js/SearchResultList.jsx
index 8bac81bfd6..d479a27a35 100644
--- a/static/js/SearchResultList.jsx
+++ b/static/js/SearchResultList.jsx
@@ -6,12 +6,9 @@ import extend from 'extend';
import classNames from 'classnames';
import $ from './sefaria/sefariaJquery';
import Sefaria from './sefaria/sefaria';
-import { SearchTotal } from "./sefaria/searchTotal";
import SearchTextResult from './SearchTextResult';
import SearchSheetResult from './SearchSheetResult';
-import SearchFilters from './SearchFilters';
import SearchState from './sefaria/searchState';
-import Strings from "./sefaria/strings.js"
import {
DropdownModal,
DropdownButton,
@@ -79,326 +76,22 @@ const SearchTopic = (props) => {
class SearchResultList extends Component {
constructor(props) {
super(props);
- this.querySize = {"text": 50, "sheet": 20};
- this.state = {
- runningQueries: null,
- isQueryRunning: false,
- moreToLoad: true,
- totals: new SearchTotal(),
- pagesLoaded: 0,
- hits: [],
- error: false,
- topics: []
- }
-
- // Load search results from cache so they are available for immediate render
-
- const args = this._getQueryArgs(props);
- let cachedQuery = Sefaria.search.getCachedQuery(args);
- while (cachedQuery) {
- // Load all pages of results that are available in cache, so if page X was
- // previously loaded it will be returned.
- //console.log("Loaded cached query for")
- //console.log(args);
- this.state.hits = this.state.hits.concat(cachedQuery.hits.hits);
- this.state.totals = cachedQuery.hits.total;
- this.state.pagesLoaded += 1;
- args.start = this.state.pagesLoaded * this.querySize[this.props.type];
- if (this.props.type === "text") {
- // Since texts only have one filter type, aggregations are only requested once on first page
- args.aggregationsToUpdate = [];
- }
- cachedQuery = Sefaria.search.getCachedQuery(args);
- }
- this.updateTotalResults();
}
componentDidMount() {
- this._executeAllQueries();
$(ReactDOM.findDOMNode(this)).closest(".content").on("scroll.infiteScroll", this.handleScroll);
}
componentWillUnmount() {
- this._abortRunningQuery(); // todo: make this work w/ promises
$(ReactDOM.findDOMNode(this)).closest(".content").off("scroll.infiniteScroll", this.handleScroll);
}
- componentWillReceiveProps(newProps) {
- if(this.props.query !== newProps.query) {
- this.setState({
- totals: new SearchTotal(),
- hits: [],
- moreToLoad: true,
- });
- this._executeAllQueries(newProps);
- } else if (this._shouldUpdateQuery(this.props, newProps, this.props.type)) {
- let state = {
- hits: [],
- pagesLoaded: 0,
- moreToLoad: true
- };
- this.setState(state, () => {
- this._executeQuery(newProps, this.props.type);
- })
- }
- }
- async addRefTopic(topic) {
- const book = await Sefaria.getIndexDetails(topic.key);
- return {
- enDesc: book.enDesc || book.enShortDesc,
- heDesc: book.heDesc || book.heShortDesc,
- title: book.title,
- heTitle: book.heTitle,
- topicCat: book.categories[0],
- heTopicCat: Sefaria.toc.filter(cat => cat.category === book.categories[0])[0].heCategory,
- url: "/" + book.title,
- analyticCat: "Book"
- }
- }
- addTOCCategoryTopic(topic) {
- const topicKeyArr = topic.key.slice();
- const lastCat = topicKeyArr.pop(topicKeyArr - 1); //go up one level in order to get the bottom level's description
- const relevantCats = topicKeyArr.length === 0 ? Sefaria.toc : Sefaria.tocItemsByCategories(topicKeyArr);
- const relevantSubCat = relevantCats.filter(cat => "category" in cat && cat.category === lastCat)[0];
- return {
- analyticCat: "Category",
- url: "/texts/" + topic.key.join("/"),
- topicCat: "Texts",
- heTopicCat: Sefaria.hebrewTerm("Texts"),
- enDesc: relevantSubCat.enDesc,
- heDesc: relevantSubCat.heDesc,
- title: relevantSubCat.category,
- heTitle: relevantSubCat.heCategory
- }
- }
- async addGeneralTopic(topic) {
- const d = await Sefaria.getTopic(topic.key, {annotated: false});
- let searchTopic = {
- analyticCat: "Topic",
- title: d.primaryTitle["en"],
- heTitle: d.primaryTitle["he"],
- numSources: 0,
- numSheets: 0,
- url: "/topics/" + topic.key
- }
- const typeObj = Sefaria.topicTocCategory(topic.key);
- if (!typeObj) {
- searchTopic.topicCat = "Topics";
- searchTopic.heTopicCat = Sefaria.hebrewTranslation("Topics");
- } else {
- searchTopic.topicCat = typeObj["en"];
- searchTopic.heTopicCat = typeObj["he"];
- }
- if ("description" in d) {
- searchTopic.enDesc = d.description["en"];
- searchTopic.heDesc = d.description["he"];
- }
- if (d.tabs?.sources) {
- searchTopic.numSources = d.tabs.sources.refs.length;
- }
- if (d.tabs?.sheets) {
- searchTopic.numSheets = d.tabs.sheets.refs.length;
- }
- return searchTopic;
- }
- async addCollection(collection) {
- const d = await Sefaria.getCollection(collection.key);
- return {
- analyticCat: "Collection",
- title: d.name,
- heTitle: d.name,
- url: "/collections/" + collection.key,
- topicCat: "Collections",
- heTopicCat: Sefaria.hebrewTranslation("Collections"),
- enDesc: d.description,
- heDesc: d.description,
- numSheets: d.sheets.length
- }
- }
- async _executeTopicQuery() {
- const d = await Sefaria.getName(this.props.query)
- let topics = d.completion_objects.filter(obj => obj.title.toUpperCase() === this.props.query.toUpperCase());
- const hasAuthor = topics.some(obj => obj.type === "AuthorTopic");
- if (hasAuthor) {
- topics = topics.filter(obj => obj.type !== "TocCategory"); //TocCategory is unhelpful if we have author
- }
- let searchTopics = await Promise.all(topics.map(async t => {
- if (t.type === 'ref') {
- return await this.addRefTopic(t);
- } else if (t.type === 'TocCategory') {
- return this.addTOCCategoryTopic(t);
- } else if (t.type === 'Collection') {
- return await this.addCollection(t);
- } else {
- return await this.addGeneralTopic(t);
- }
- }));
- this.setState({topics: searchTopics});
- }
- updateRunningQuery(ajax) {
- this.state.runningQueries = ajax;
- this.state.isQueryRunning = !!ajax;
- this.setState(this.state);
- }
- totalResults() {
- return this.state.totals;
- }
- updateTotalResults() {
- this.props.updateTotalResults(this.totalResults());
- }
- _abortRunningQuery() {
- if(this.state.runningQueries) {
- this.state.runningQueries.abort(); //todo: make work with promises
- }
- this.updateRunningQuery(null);
- }
handleScroll() {
- if (!this.state.moreToLoad) { return; }
- if (this.state.runningQueries) { return; }
+ if (!this.props.moreToLoad) { return; }
+ if (this.props.isQueryRunning) { return; }
var $scrollable = $(ReactDOM.findDOMNode(this)).closest(".content");
var margin = 300;
if($scrollable.scrollTop() + $scrollable.innerHeight() + margin >= $scrollable[0].scrollHeight) {
- this._loadNextPage();
- }
- }
- _shouldUpdateQuery(oldProps, newProps) {
- const oldSearchState = this._getSearchState(oldProps);
- const newSearchState = this._getSearchState(newProps);
- return !oldSearchState.isEqual({ other: newSearchState, fields: ['appliedFilters', 'field', 'sortType'] }) ||
- ((oldSearchState.filtersValid !== newSearchState.filtersValid) && oldSearchState.appliedFilters.length > 0); // Execute a second query to apply filters after an initial query which got available filters
- }
- _getSearchState(props) {
- props = props || this.props;
- if (!props.query) {
- return;
+ this.props.loadNextPage();
}
- return props['searchState'];
- }
- _executeAllQueries(props) {
- this._executeTopicQuery();
- this._executeQuery(props, this.props.type);
- }
- _getAggsToUpdate(filtersValid, aggregation_field_array, aggregation_field_lang_suffix_array, appliedFilterAggTypes, type) {
- // Returns a list of aggregations type which we should request from the server.
-
- // If there is only on possible filter (i.e. path for text) and filters are valid, no need to request again for any filter interactions
- if (filtersValid && aggregation_field_array.length === 1) { return []; }
-
- return Sefaria.util
- .zip(aggregation_field_array, aggregation_field_lang_suffix_array)
- .map(([agg, suffix_map]) => `${agg}${suffix_map ? suffix_map[Sefaria.interfaceLang] : ''}`); // add suffix based on interfaceLang to filter, if present in suffix_map
- }
- _executeQuery(props) {
- //This takes a props object, so as to be able to handle being called from componentWillReceiveProps with newProps
- props = props || this.props;
- if (!props.query) {
- return;
- }
- this._abortRunningQuery();
-
- let args = this._getQueryArgs(props);
-
- // If there are no available filters yet, don't apply filters. Split into two queries:
- // 1) Get all potential filters and counts
- // 2) Apply filters (Triggered from componentWillReceiveProps)
-
- const request_applied = args.applied_filters;
- const searchState = this._getSearchState(props);
- const { appliedFilters, appliedFilterAggTypes } = searchState;
- const { aggregation_field_array, build_and_apply_filters } = SearchState.metadataByType[this.props.type];
-
- args.success = data => {
- this.updateRunningQuery(null);
- if (this.state.pagesLoaded === 0) { // Skip if pages have already been loaded from cache, but let aggregation processing below occur
- const currTotal = data.hits.total;
- let state = {
- hits: data.hits.hits,
- totals: currTotal,
- pagesLoaded: 1,
- moreToLoad: currTotal.getValue() > this.querySize[this.props.type]
- };
- this.setState(state, () => {
- this.updateTotalResults();
- this.handleScroll();
- });
- const filter_label = (request_applied && request_applied.length > 0) ? (' - ' + request_applied.join('|')) : '';
- const query_label = props.query + filter_label;
- Sefaria.track.event("Search", `${this.props.searchInBook? "SidebarSearch ": ""}Query: ${this.props.type}`, query_label, data.hits.total.getValue());
- }
-
- if (data.aggregations) {
- let availableFilters = [];
- let registry = {};
- let orphans = [];
- for (let aggregation of args.aggregationsToUpdate) {
- if (!!data.aggregations[aggregation]) {
- const { buckets } = data.aggregations[aggregation];
- const {
- availableFilters: tempAvailable,
- registry: tempRegistry,
- orphans: tempOrphans
- } = Sefaria.search[build_and_apply_filters](buckets, appliedFilters, appliedFilterAggTypes, aggregation);
- availableFilters.push(...tempAvailable); // array concat
- registry = extend(registry, tempRegistry);
- orphans.push(...tempOrphans);
- }
- }
- this.props.registerAvailableFilters(this.props.type, availableFilters, registry, orphans, args.aggregationsToUpdate);
- }
- };
- args.error = this._handleError;
-
- const runningQuery = Sefaria.search.execute_query(args);
- this.updateRunningQuery(runningQuery);
- }
- _getQueryArgs(props) {
- props = props || this.props;
-
- const searchState = this._getSearchState(props);
- const { field, fieldExact, sortType, filtersValid, appliedFilters, appliedFilterAggTypes } = searchState;
- const request_applied = filtersValid && appliedFilters;
- const { aggregation_field_array, aggregation_field_lang_suffix_array } = SearchState.metadataByType[this.props.type];
- const aggregationsToUpdate = this._getAggsToUpdate(filtersValid, aggregation_field_array, aggregation_field_lang_suffix_array, appliedFilterAggTypes, this.props.type);
-
- return {
- query: props.query,
- type: this.props.type,
- applied_filters: request_applied,
- appliedFilterAggTypes,
- aggregationsToUpdate,
- size: this.querySize[this.props.type],
- field,
- sort_type: sortType,
- exact: fieldExact === field,
- };
- }
- _loadNextPage() {
- console.log("load next page")
- const args = this._getQueryArgs(this.props);
- args.start = this.state.pagesLoaded * this.querySize[this.props.type];
- args.error = () => console.log("Failure in SearchResultList._loadNextPage");
- args.success = data => {
- let nextHits = this.state.hits.concat(data.hits.hits);
-
- this.state.hits = nextHits;
- this.state.pagesLoaded += 1;
- if (this.state.pagesLoaded * this.querySize[this.props.type] >= this.state.totals.getValue() ) {
- this.state.moreToLoad = false;
- }
-
- this.setState(this.state);
- this.updateRunningQuery(null);
- };
-
- const runningNextPageQuery = Sefaria.search.execute_query(args);
- this.updateRunningQuery(runningNextPageQuery, false);
- }
- _handleError(jqXHR, textStatus, errorThrown) {
- if (textStatus == "abort") {
- // Abort is immediately followed by new query, above. Worried there would be a race if we call updateCurrentQuery(null) from here
- //this.updateCurrentQuery(null);
- return;
- }
- this.setState({error: true});
- this.updateRunningQuery(null);
}
render () {
if (!(this.props.query)) { // Push this up? Thought is to choose on the SearchPage level whether to show a ResultList or an EmptySearchMessage.
@@ -406,11 +99,10 @@ class SearchResultList extends Component {
}
const { type } = this.props;
- const searchState = this._getSearchState();
let results = [];
if (type === "text") {
- results = Sefaria.search.mergeTextResultsVersions(this.state.hits);
+ results = Sefaria.search.mergeTextResultsVersions(this.props.hits);
results = results.filter(result => !!result._source.version).map(result =>
);
- if (this.state.topics.length > 0) {
- let topics = this.state.topics.map(t => {
+ if (this.props.topics.length > 0) {
+ let topics = this.props.topics.map(t => {
Sefaria.track.event("Search", "topic in search display", t.analyticCat+"|"+t.title);
return
});
@@ -435,10 +127,10 @@ class SearchResultList extends Component {
} else if (type === "sheet") {
- results = this.state.hits.map(result =>
+ results = this.props.hits.map((result, i) =>
@@ -447,56 +139,51 @@ class SearchResultList extends Component {
const loadingMessage = ();
const noResultsMessage = ();
-
- const queryFullyLoaded = !this.state.moreToLoad && !this.state.isQueryRunning;
+ const queryFullyLoaded = !this.props.moreToLoad && !this.props.isQueryRunning;
const haveResults = !!results.length;
results = haveResults ? results : noResultsMessage;
return (
-
- {Sefaria.multiPanel && !this.props.compare ?
-
- :
- }
-
-
- { queryFullyLoaded || haveResults ? results : null }
- { this.state.isQueryRunning ? loadingMessage : null }
-
+
+ {queryFullyLoaded || haveResults ? results : null}
+ {this.props.isQueryRunning ? loadingMessage : null}
+
);
}
}
+
SearchResultList.propTypes = {
- query: PropTypes.string,
- type: PropTypes.oneOf(["text", "sheet"]),
- searchState: PropTypes.object,
- onResultClick: PropTypes.func,
- updateAppliedOptionSort: PropTypes.func,
- registerAvailableFilters: PropTypes.func,
+ query: PropTypes.string,
+ type: PropTypes.oneOf(["text", "sheet"]),
+ searchState: PropTypes.object,
+ onResultClick: PropTypes.func,
+ updateAppliedOptionSort: PropTypes.func,
+ registerAvailableFilters: PropTypes.func,
+ loadNextPage: PropTypes.func,
+ queryFullyLoaded: PropTypes.bool,
+ isQueryRunning: PropTypes.bool,
+ topics: PropTypes.array
};
-const SearchSortBox = ({type, updateAppliedOptionSort, sortType}) => {
- const [isOpen, setIsOpen] = useState(false);
+const SearchSortBox = ({type, updateAppliedOptionSort, sortType, sortTypeArray}) => {
+ const [isOpen, setIsOpen] = useState(false);
- const handleClick = (newSortType) => {
- if (sortType === newSortType) {
- return;
- }
- updateAppliedOptionSort(type, newSortType);
- setIsOpen(false);
- }
- const filterTextClasses = classNames({ searchFilterToggle: 1, active: isOpen });
- return (
- {setIsOpen(false)}} isOpen={isOpen}>
- {
+ if (sortType === newSortType) {
+ return;
+ }
+ updateAppliedOptionSort(type, newSortType);
+ setIsOpen(false);
+ }
+ const filterTextClasses = classNames({searchFilterToggle: 1, active: isOpen});
+ return (
+ {
+ setIsOpen(false)
+ }} isOpen={isOpen}>
+ {setIsOpen(!isOpen)}}
enText={"Sort"}
heText={"מיון"}
@@ -504,7 +191,7 @@ const SearchSortBox = ({type, updateAppliedOptionSort, sortType}) => {
/>
@@ -526,4 +213,4 @@ const SearchFilterButton = ({openMobileFilters, nFilters}) => (
);
-export default SearchResultList;
+export { SearchResultList, SearchFilterButton, SearchSortBox };
diff --git a/static/js/SearchSheetResult.jsx b/static/js/SearchSheetResult.jsx
index 3de7ed4f45..f0dec7ec4b 100644
--- a/static/js/SearchSheetResult.jsx
+++ b/static/js/SearchSheetResult.jsx
@@ -78,8 +78,7 @@ class SearchSheetResult extends Component {
SearchSheetResult.propTypes = {
query: PropTypes.string,
- metadata: PropTypes.object,
- snippet: PropTypes.string,
+ hit: PropTypes.object,
onResultClick: PropTypes.func
};
diff --git a/static/js/SidebarSearch.jsx b/static/js/SidebarSearch.jsx
index 00e0509f50..24ef47904d 100644
--- a/static/js/SidebarSearch.jsx
+++ b/static/js/SidebarSearch.jsx
@@ -1,11 +1,9 @@
import { useState, useEffect } from "react";
-import {InterfaceText, EnglishText, HebrewText} from "./Misc";
import Sefaria from "./sefaria/sefaria";
import SearchState from './sefaria/searchState';
-import SearchResultList from './SearchResultList';
import DictionarySearch from './DictionarySearch';
import classNames from 'classnames';
-
+import {ElasticSearchQuerier} from "./ElasticSearchQuerier";
import {
SearchButton,
} from './Misc';
@@ -111,22 +109,18 @@ const SidebarSearch = ({ title, updateAppliedOptionSort, navigatePanel, sidebarS
- {query ?
-
console.log(n)}
- registerAvailableFilters={n => console.log(n)}
- updateAppliedOptionSort={updateAppliedOptionSort}
- onResultClick={onSidebarSearchClick}
- /> :
-
- null
-
- }
+ {query &&
+ console.log(n)}
+ registerAvailableFilters={n => console.log(n)}
+ updateAppliedOptionSort={updateAppliedOptionSort}
+ onResultClick={onSidebarSearchClick}
+ />}
diff --git a/static/js/StaticPages.jsx b/static/js/StaticPages.jsx
index bd59a652ba..bb8721dbd8 100644
--- a/static/js/StaticPages.jsx
+++ b/static/js/StaticPages.jsx
@@ -3,7 +3,10 @@ import {
SimpleInterfaceBlock,
TwoOrThreeBox,
ResponsiveNBox,
- NBox, InterfaceText,
+ NBox,
+ InterfaceText,
+ HebrewText,
+ EnglishText,
LoadingMessage,
LoadingRing,
} from './Misc';
@@ -11,6 +14,9 @@ import {NewsletterSignUpForm} from "./NewsletterSignUpForm";
import palette from './sefaria/palette';
import classNames from 'classnames';
import Cookies from 'js-cookie';
+import ReactMarkdown from 'react-markdown';
+import Sefaria from './sefaria/sefaria';
+import { OnInView, handleAnalyticsOnMarkdown } from './Misc';
/* Templates:
@@ -3137,6 +3143,328 @@ const JobsPage = memo(() => {
);
});
+
+/*
+* Products Page
+*/
+
+// The static content on the page inviting users to browse our "powered-by" products
+const DevBox = () => {
+ return (
+
+
+
+
+
+
+
+ נסו את המוצרים שמפתחי תוכנה וידידי ספריא מרחבי העולם בנו עבורכם! גלו את הפרויקטים
+
+
+ Check out the products our software developer friends from around the world have been building for you! Explore
+
+
+
+
+ );
+ };
+
+/**
+ * The following are the building block components of an individual product.
+ */
+
+// The title and gray background label for each product
+const ProductTitle = ({product}) => {
+ return (
+
+
+
+
+ {product.type.en ? (
+
+ ) : ''}
+
+ );
+};
+
+// Generalized function for catching products page analytics - to be revisited
+const productsAnalytics = (rank, product, cta, label, link_type, event) => {
+ gtag("event", `products_${event}`, {
+ project: 'Products',
+ panel_type: "strapi-static",
+ panel_number: 1,
+ panel_name: "Products",
+ position: rank,
+ link_text: cta,
+ experiment: label === 'Experiment' ? 1 : 0,
+ feature_name: product,
+ link_classes: link_type,
+ engagement_type: "navigation",
+ engagement_value: 0
+ });
+}
+
+
+// The call-to-action (link) in the heading of each product
+// For desc link, change cta text to desc and "cta" to desc
+// TODO - uncomment
once analytics is confirmed
+const ProductCTA = ({product, cta}) => {
+ return (
+
+ //
productsAnalytics(product?.rank, product?.titles.en, cta.text.en, product?.type.en, "viewed")}>
+ productsAnalytics(product.rank, product.titles.en, cta.text.en, product.type.en, "cta", "clicked")}>
+ {cta.icon.url && }
+
+
+
+
+
+
+ //
+
+
+ );
+};
+
+// The main body of each product entry, containing an image and description
+const ProductDesc = ({product}) => {
+ return (
+
+
+
+
+
handleAnalyticsOnMarkdown(e, productsAnalytics, product.rank, product.titles.en, null, null, "product_desc", "clicked")}>
+
+
+
+ );
+};
+
+// The main product component, comprised of the building block sub-components
+const Product = ({product}) => {
+ return (
+
+
+
+
+ {product.ctaLabels?.map(cta => (
+
+ ))}
+
+
+
+
+ );
+};
+
+
+
+
+const ProductsPage = memo(() => {
+ const [products, setProducts] = useState([]);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ loadProducts();
+ }, []);
+
+ // GraphQL query to Strapi
+ const fetchProductsJSON = async () => {
+
+ // If the viewer is an admin, edit the query to retrieve the drafts as well
+ var includeDrafts = '';
+ if (Sefaria.is_moderator){
+ includeDrafts = 'publicationState:PREVIEW,';
+ }
+
+ const query = `query {
+ products (
+ pagination: { limit: -1 },
+ ${includeDrafts}
+ sort: "rank:asc"
+ )
+ {
+ data {
+ id
+ attributes {
+ title
+ rank
+ url
+ type
+ description
+ rectanglion {
+ data {
+ attributes {
+ url
+ alternativeText
+ }
+ }
+ }
+ createdAt
+ updatedAt
+ locale
+ call_to_actions {
+ data {
+ id
+ attributes {
+ text
+ url
+ icon {
+ data {
+ id
+ attributes {
+ url
+ alternativeText
+ }
+ }
+ }
+ locale
+ localizations {
+ data {
+ id
+ attributes {
+ text
+ }
+ }
+ }
+ }
+ }
+ }
+ localizations {
+ data {
+ attributes {
+ locale
+ title
+ type
+ description
+ }
+ }
+ }
+ }
+ }
+ }
+ }`;
+
+ try {
+ const response = await fetch(STRAPI_INSTANCE + "/graphql", {
+ method: "POST",
+ mode: "cors",
+ cache: "no-cache",
+ credentials: "omit",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ redirect: "follow",
+ referrerPolicy: "no-referrer",
+ body: JSON.stringify({ query }),
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP Error: ${response.statusText}`);
+ }
+ const data = await response.json();
+ return data;
+ } catch (error) {
+ throw error;
+ }
+ };
+
+ // Loading Products data, and setting the state ordering the products by their `rank`
+ const loadProducts = async () => {
+ if (typeof STRAPI_INSTANCE !== "undefined" && STRAPI_INSTANCE) {
+ try {
+ const productsData = await fetchProductsJSON();
+
+ const productsFromStrapi = productsData.data?.products?.data?.map((productsData) => {
+
+ const heLocalization = productsData.attributes?.localizations?.data[0]?.attributes;
+ const ctaLabels = productsData.attributes?.call_to_actions?.data;
+
+ const ctaLabelsLocalized = ctaLabels.map((cta) => {
+ return {
+ text: {
+ en: cta.attributes?.text,
+ he: cta.attributes?.localizations?.data[0]?.attributes.text
+ },
+ url: cta.attributes?.url,
+ icon: {
+ url: cta.attributes?.icon?.data?.attributes?.url,
+ },
+ id: cta.id
+ };
+ });
+
+ return {
+ id: productsData.id,
+ titles: {
+ en: productsData.attributes?.title,
+ he: heLocalization?.title
+ },
+ rank: productsData.attributes?.rank,
+ type: {
+ en: productsData.attributes?.type,
+ he: productsData.attributes?.localizations?.data[0]?.attributes?.type,
+ },
+ url: productsData.attributes?.url,
+ desc:
+ {
+ en: productsData.attributes?.description,
+ he: heLocalization?.description
+ },
+ rectanglion: {
+ url: productsData.attributes?.rectanglion?.data?.attributes?.url,
+ },
+ ctaLabels: ctaLabelsLocalized,
+
+ };
+ }, {});
+ setProducts(productsFromStrapi);
+ } catch (error) {
+ console.error("Fetch error:", error);
+ setError("Error: Sefaria's CMS cannot be reached");
+ }
+ } else {
+ setError("Error: Sefaria's CMS cannot be reached");
+ }
+ };
+
+
+ // In order to inject the static 'DevBox' in a fixed position on the page, we
+ // create an array of
components, and then slice the list into two sub-lists at the
+ // desired insertion position for the 'DevBox'. When rendering, we render the
+ // first sub-list, the
, and finally the second sub-list.
+ const ProductList = [];
+ if (products) {
+ for (const product of products) {
+ ProductList.push(
)
+ }
+ }
+
+ const devBoxPosition = 2;
+ const initialProducts = ProductList.slice(0, devBoxPosition);
+ const remainingProducts = ProductList.slice(devBoxPosition);
+
+ return (
+ <>
+
+ Sefaria's Products
+ מוצרים של בספריא
+
+
+ {products && products.length > 0 ? (
+ <>
+ {initialProducts}
+ {/* */}
+ {remainingProducts}
+ >
+ ) : null}
+
+ >
+ );
+});
+
+
export {
RemoteLearningPage,
SheetsLandingPage,
@@ -3150,5 +3478,6 @@ export {
DonatePage,
WordByWordPage,
JobsPage,
- TeamMembersPage
+ TeamMembersPage,
+ ProductsPage,
};
diff --git a/static/js/TextRange.jsx b/static/js/TextRange.jsx
index 38441ac3b3..777f34ada4 100644
--- a/static/js/TextRange.jsx
+++ b/static/js/TextRange.jsx
@@ -506,8 +506,8 @@ class TextSegment extends Component {
handleRefLinkClick(refLink, event) {
event.preventDefault();
let newRef = Sefaria.humanRef(refLink.attr("data-ref"));
- const newBook = Sefaria.parseRef(newRef)?.book;
- const currBook = Sefaria.parseRef(this.props.sref)?.book;
+ const newBook = Sefaria.parseRef(newRef)?.index;
+ const currBook = Sefaria.parseRef(this.props.sref)?.index;
const isScrollLink = refLink.attr('data-scroll-link');
// two options: in most cases, we open a new panel, but if isScrollLink is 'true', we should navigate in the same panel to the new location
diff --git a/static/js/TopicPageAll.jsx b/static/js/TopicPageAll.jsx
index d732d7c0cd..c7addcd444 100644
--- a/static/js/TopicPageAll.jsx
+++ b/static/js/TopicPageAll.jsx
@@ -45,7 +45,6 @@ class TopicPageAll extends Component {
const sidebarModules = [
{type: "Promo"},
{type: "TrendingTopics"},
- {type: "JoinTheConversation"},
{type: "GetTheApp"},
{type: "SupportSefaria"},
];
diff --git a/static/js/TopicsPage.jsx b/static/js/TopicsPage.jsx
index b79ab2c52c..6d61844374 100644
--- a/static/js/TopicsPage.jsx
+++ b/static/js/TopicsPage.jsx
@@ -50,7 +50,6 @@ const TopicsPage = ({setNavTopic, multiPanel, initialWidth}) => {
const sidebarModules = [
multiPanel ? {type: "AboutTopics"} : {type: null},
{type: "TrendingTopics"},
- {type: "JoinTheConversation"},
{type: "GetTheApp"},
{type: "SupportSefaria"},
];
diff --git a/static/js/UserProfile.jsx b/static/js/UserProfile.jsx
index 37969a2c49..424acacd51 100644
--- a/static/js/UserProfile.jsx
+++ b/static/js/UserProfile.jsx
@@ -342,7 +342,7 @@ class UserProfile extends Component {
return (
- {this.props.profile.show_editor_toggle ?
: null}
+ {(this.props.profile.id === Sefaria._uid && this.props.profile.show_editor_toggle) ?
: null}
{ !this.props.profile.id ?
:
diff --git a/static/js/sefaria/sefaria.js b/static/js/sefaria/sefaria.js
index c9b99d7673..4bbcf901a5 100644
--- a/static/js/sefaria/sefaria.js
+++ b/static/js/sefaria/sefaria.js
@@ -10,6 +10,8 @@ import Hebrew from './hebrew';
import Util from './util';
import $ from './sefariaJquery';
import Cookies from 'js-cookie';
+import SearchState from "./searchState";
+import FilterNode from "./FilterNode";
let Sefaria = Sefaria || {
@@ -274,6 +276,12 @@ Sefaria = extend(Sefaria, {
let index = Sefaria.index(pRef.index);
return index && index.categories ? index.categories : [];
},
+ refIndexTitle: function(ref) {
+ let pRef = Sefaria.parseRef(ref);
+ if ("error" in pRef) { return null; }
+ let index = Sefaria.index(pRef.index);
+ return index?.title
+ },
sectionRef: function(ref, deriveIfNotFound=false) {
// Returns the section level ref for `ref` or null if no data is available
const oref = this.getRefFromCache(ref);
@@ -463,29 +471,61 @@ Sefaria = extend(Sefaria, {
});
},
_bulkTexts: {},
+ partitionArrayForURL: function(arr, urlMaxLength, dividerToken) {
+ const result = [];
+ const dividerTokenLength = encodeURIComponent(dividerToken).length;
+ let currentPartition = [];
+ let currentLength = 0;
+
+ for (let i = 0; i < arr.length; i++) {
+ // Calculate the length of the new item when added to the partition
+ const item = arr[i];
+ const encodedItem = encodeURIComponent(item);
+ const newLength = currentPartition.length === 0
+ ? encodedItem.length
+ : currentLength + encodedItem.length + dividerTokenLength; // consider dividerToken length
+
+ // Check if adding this item exceeds the max length
+ if (newLength > urlMaxLength) {
+ // If it does, push the current partition to the result and start a new one
+ result.push(currentPartition);
+ currentPartition = [];
+ currentLength = 0;
+ currentPartition.push(item);
+ continue
+ }
+
+ // Add the item to the current partition
+ currentPartition.push(item);
+ currentLength = newLength;
+ }
+
+ // Add the last partition to the result
+ if (currentPartition.length > 0) {
+ result.push(currentPartition);
+ }
+
+ return result;
+},
+
getBulkText: function(refs, asSizedString=false, minChar=null, maxChar=null, transLangPref=null) {
if (refs.length === 0) { return Promise.resolve({}); }
const MAX_URL_LENGTH = 3800;
- const hostStr = `${Sefaria.apiHost}/api/bulktext/`;
+ const ASSUMED_HOSTNAME_LENGTH_BOUND = 50;
+ const hostStr = encodeURI(`${Sefaria.apiHost}/api/bulktext/`);
let paramStr = '';
for (let [paramKey, paramVal] of Object.entries({asSizedString, minChar, maxChar, transLangPref})) {
paramStr = !!paramVal ? paramStr + `&${paramKey}=${paramVal}` : paramStr;
}
paramStr = paramStr.replace(/&/,'?');
+ paramStr = encodeURI(paramStr);
// Split into multiple requests if URL length goes above limit
- let refStrs = [""];
- refs.map(ref => {
- let last = refStrs[refStrs.length-1];
- const encodedFullURL = encodeURI(`${hostStr}${last}|${ref}${paramStr}`);
- if (encodedFullURL.length > MAX_URL_LENGTH) {
- refStrs.push(ref)
- } else {
- refStrs[refStrs.length-1] += last.length ? `|${ref}` : ref;
- }
- });
+ const limit = MAX_URL_LENGTH-(hostStr+paramStr).length-ASSUMED_HOSTNAME_LENGTH_BOUND
+ const refsSubArrays = this.partitionArrayForURL( refs, limit, '|');
+ const refStrs = refsSubArrays.map(refsSubArray => refsSubArray.join('|'));
let promises = refStrs.map(refStr => this._cachedApiPromise({
url: `${hostStr}${encodeURIComponent(refStr)}${paramStr}`,
@@ -2779,6 +2819,56 @@ _media: {},
return Sefaria.topic_toc.filter(x => x.slug == slug).length > 0;
},
sheets: {
+ getSheetsByRef: function(srefs, callback) {
+ return Sefaria._cachedApiPromise({
+ url: `${Sefaria.apiHost}/api/sheets/ref/${srefs}?include_collections=1`,
+ key: `include_collections|${srefs}`,
+ store: Sefaria.sheets._sheetsByRef,
+ processor: callback
+ });
+ },
+ sheetsWithRefFilterNodes(sheets) {
+ /*
+ This function is used to generate the SearchState with its relevant
+ FilterNodes to be used by SheetsWithRef for filtering sheets by topic and collection
+ */
+ const newFilter = (item, type) => {
+ let title, heTitle;
+ if (type === 'topics') {
+ [title, heTitle] = [item.en, item.he];
+ type = 'topics_en';
+ }
+ else if (type === 'collections') {
+ [title, heTitle] = [item.name, item.name];
+ }
+ return {
+ title, heTitle,
+ docCount: 0, aggKey: item.slug,
+ selected: 0, aggType: type,
+ };
+ }
+
+ let filters = {};
+ sheets.forEach(sheet => {
+ let slugsFound = new Set(); // keep track of slugs in this sheet\n
+ ['topics', 'collections'].forEach(itemsType => {
+ sheet[itemsType]?.forEach(item => {
+ const key = `${item.slug}|${itemsType}`;
+ if (!slugsFound.has(key)) { // we don't want to increase docCount when one sheet already
+ // has a topic/collection with the same slug as the current topic/collection
+ let filter = filters[key];
+ if (!filter) {
+ filter = newFilter(item, itemsType);
+ filters[key] = filter;
+ }
+ slugsFound.add(key);
+ filter.docCount += 1;
+ }
+ })
+ })
+ })
+ return Object.values(filters).map(f => new FilterNode(f));;
+ },
_loadSheetByID: {},
loadSheetByID: function(id, callback, reset) {
if (reset) {
@@ -2943,10 +3033,10 @@ _media: {},
},
sheetsTotalCount: function(refs) {
// Returns the total number of private and public sheets on `refs` without double counting my public sheets.
- var sheets = Sefaria.sheets.sheetsByRef(refs) || [];
+ let sheets = Sefaria.sheets.sheetsByRef(refs) || [];
if (Sefaria._uid) {
- var mySheets = Sefaria.sheets.userSheetsByRef(refs) || [];
- sheets = sheets.filter(function(sheet) { return sheet.owner !== Sefaria._uid }).concat(mySheets);
+ const mySheets = Sefaria.sheets.userSheetsByRef(refs) || [];
+ sheets = mySheets.concat(sheets.filter(function(sheet) { return sheet.owner !== Sefaria._uid }));
}
return sheets.length;
},
diff --git a/static/js/sefaria/sheetsUtils.js b/static/js/sefaria/sheetsUtils.js
new file mode 100644
index 0000000000..850f9167b4
--- /dev/null
+++ b/static/js/sefaria/sheetsUtils.js
@@ -0,0 +1,69 @@
+import Sefaria from "./sefaria";
+
+export async function getSegmentObjs(refs) {
+ /*
+ Given an array of ref-strings (could also be ranged refs),
+ turn each ref to segment object and return an array of all segments
+ */
+ const segments = [];
+
+ for (const ref of refs) {
+ const text = await Sefaria.getText(ref, { stripItags: 1 });
+ const newSegments = Sefaria.makeSegments(text, false);
+ segments.push(...newSegments);
+ }
+ return segments;
+}
+export async function getNormalRef(ref) {
+ /*
+ Given a ref-string, get he and en normal ref-string
+ */
+ const refObj = await Sefaria.getRef(ref);
+ return {en: refObj.ref, he: refObj.heRef}
+}
+function placedSegmentMapper(lang, segmented, includeNumbers, s) {
+ /*
+ Map each segment object to a formatted text string
+ */
+ if (!s[lang]) {return ""}
+
+ let numStr = "";
+ if (includeNumbers) {
+ const num = (lang=="he") ? Sefaria.hebrew.encodeHebrewNumeral(s.number) : s.number;
+ numStr = "
(" + num + ") ";
+ }
+ let str = "
" + numStr + s[lang] + " ";
+ if (segmented) {
+ str = "
" + str + "
";
+ }
+ str = str.replace(/(
)+/g, ' ')
+ return str;
+}
+export const segmentsToSourceText = (segments, lan) => {
+ /*
+ Turn array of segment objects into one chunk of formatted text
+ */
+ const segmented = shouldBeSegmented(segments[0].ref);
+ const includeNumbers = shouldIncludeSegmentNums(segments[0].ref);
+ return(segments.map(placedSegmentMapper.bind(this, lan, segmented, includeNumbers))
+ .filter(Boolean)
+ .join(""));
+}
+function shouldIncludeSegmentNums(ref){
+ /*
+ Decide if segment of this ref should have segment numbers when turned into text chunk
+ */
+ const indexTitle = Sefaria.refIndexTitle(ref);
+ const categories = Sefaria.refCategories(ref);
+ if (categories.includes("Talmud")) {return false}
+ if (indexTitle === "Pesach Haggadah") {return false}
+ if (categories === 1) {return false}
+ return true;
+}
+function shouldBeSegmented(ref){
+ /*
+ Decide if segment of this ref should be followed by new line when turned into text chunk
+ */
+ const categories = Sefaria.refCategories(ref);
+ return !(categories[0] in {"Tanakh": 1, "Talmud": 1});
+}
\ No newline at end of file
diff --git a/static/js/sheets/SheetsWithRefPage.jsx b/static/js/sheets/SheetsWithRefPage.jsx
new file mode 100644
index 0000000000..b53a4ad5f4
--- /dev/null
+++ b/static/js/sheets/SheetsWithRefPage.jsx
@@ -0,0 +1,196 @@
+import SearchPage from "../SearchPage";
+import Sefaria from "../sefaria/sefaria";
+import {useEffect, useState} from "react";
+import {SearchTotal} from "../sefaria/searchTotal";
+import SearchState from "../sefaria/searchState";
+const SheetsWithRefPage = ({srefs, searchState, updateSearchState, updateAppliedFilter,
+ updateAppliedOptionField, updateAppliedOptionSort, onResultClick,
+ registerAvailableFilters}) => {
+ const [sheets, setSheets] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const [origAvailableFilters, setOrigAvailableFilters] = useState([]);
+ // storing original available filters is crucial so that we have access to the full list of filters.
+ // by contrast, in the searchState, the available filters length changes based on filtering.
+ // by having access to the original available filters list, if the searchState's applied filters are turned off,
+ // we can return the searchState's available filters to the original list. Once origAvailableFilters are loaded,
+ // they are never changed.
+
+ const [refs, setRefs] = useState(srefs);
+ const sortTypeArray = SearchState.metadataByType['sheet'].sortTypeArray.filter(sortType => sortType.type !== 'relevance');
+
+ const cloneFilters = (availableFilters, resetDocCounts = true) => {
+ // clone filters so that we can update the available filters docCounts
+ // without modifying the original available filters (origAvailableFilters) docCounts
+ // we don't want to modify the origAvailableFilters docCounts so that we have the accurate number when checking
+ // in checkForRegisteringAvailableFilters
+ return availableFilters.map(availableFilter => {
+ let newAvailableFilter = availableFilter.clone();
+ if (resetDocCounts) newAvailableFilter.docCount = 0;
+ return newAvailableFilter;
+ })
+ }
+ const getDocCounts = (availableFilters) => {
+ return availableFilters.map(availableFilter => availableFilter.docCount).sort((a, b) => a - b);
+ }
+ const checkForRegisteringAvailableFilters = (availableFilters) => {
+ const newDocCounts = getDocCounts(availableFilters);
+ const currDocCounts = getDocCounts(searchState.availableFilters);
+ if (!newDocCounts.compare(currDocCounts)) { // if previously the appliedFilters were different,
+ // then the doccounts will be different, so register
+ availableFilters = availableFilters.sort((a, b) => b.docCount - a.docCount || a.title.localeCompare(b.title));
+ registerAvailableFilters('sheet', availableFilters, {}, [], ['collections', 'topics_en']);
+ }
+ }
+
+ const getSheetSlugs = (type, sheet) => {
+ const items = type === 'topics_en' ? sheet.topics : sheet.collections;
+ return items.map(x => x.slug);
+ }
+ const applyFiltersToSheets = (sheets) => {
+ searchState.appliedFilters.forEach((appliedFilter, i) => {
+ const type = searchState.appliedFilterAggTypes[i];
+ sheets = sheets.filter(sheet => {
+ const slugs = getSheetSlugs(type, sheet);
+ return slugs.includes(appliedFilter);
+ });
+ });
+ return sheets;
+ }
+
+ const applyFilters = (sheets) => {
+ if (searchState.appliedFilters.length === 0) {
+ checkForRegisteringAvailableFilters(origAvailableFilters);
+ }
+ else {
+ let newAvailableFilters = cloneFilters(origAvailableFilters);
+ sheets = applyFiltersToSheets(sheets);
+ newAvailableFilters = updateFilterDocCounts(newAvailableFilters, sheets);
+ newAvailableFilters = removeEmptyFilters(newAvailableFilters);
+ newAvailableFilters = updateFilterSelectedValues(newAvailableFilters);
+ checkForRegisteringAvailableFilters(newAvailableFilters);
+ }
+ return sheets;
+ }
+ const updateFilterSelectedValues = (availableFilters) => {
+ availableFilters.forEach((availableFilter) => {
+ const selected = searchState.appliedFilters.includes(availableFilter.aggKey);
+ if (selected !== Boolean(availableFilter.selected)) {
+ if (selected) {
+ availableFilter.setSelected(true);
+ } else {
+ availableFilter.setUnselected(true);
+ }
+ }
+ })
+ return availableFilters;
+ }
+ const removeEmptyFilters = (availableFilters) => {
+ return availableFilters.filter(availableFilter => availableFilter.docCount > 0);
+ }
+ const updateFilterDocCounts = (availableFilters, sheets) => {
+ ['collections', 'topics_en'].forEach(type => {
+ let allSlugs = {};
+ sheets.forEach(sheet => {
+ let slugs = getSheetSlugs(type, sheet);
+ slugs = [...new Set(slugs)]; // don't double count slugs since there are duplicates
+ slugs.forEach(slug => {
+ if (!(slug in allSlugs)) {
+ allSlugs[slug] = 0;
+ }
+ allSlugs[slug] += 1;
+ })
+ })
+ availableFilters.forEach((filter, i) => {
+ if (filter.aggKey in allSlugs && filter.aggType === type) {
+ availableFilters[i].docCount = allSlugs[filter.aggKey];
+ }
+ })
+ })
+ return availableFilters;
+ }
+ const applySortOption = (sheets) => {
+ switch(searchState.sortType) {
+ case 'views':
+ sheets = sheets.sort((a, b) => b.views - a.views);
+ break;
+ case 'dateCreated':
+ sheets = sheets.sort((a, b) => new Date(b.dateCreated) - new Date(a.dateCreated));
+ break;
+ }
+ return sheets;
+ }
+ const prepSheetsForDisplay = (sheets) => {
+ sheets = sheets.sort((a, b) => {
+ // First place user's sheet
+ if (a.owner === Sefaria.uid && b.owner !== Sefaria.uid) {
+ return -1;
+ }
+ if (a.owner !== Sefaria.uid && b.owner === Sefaria.uid) {
+ return 1;
+ }
+ // Then sort by language / interface language
+ let aHe, bHe;
+ [aHe, bHe] = [a.title, b.title].map(Sefaria.hebrew.isHebrew);
+ if (aHe !== bHe) { return (bHe ? -1 : 1) * (Sefaria.interfaceLang === "hebrew" ? -1 : 1); }
+ })
+ return sheets;
+ }
+ const normalizeSheetsMetaData = (sheets) => {
+ return sheets.map(sheet => {
+ return {
+ sheetId: sheet.id,
+ title: sheet.title,
+ owner_name: sheet.ownerName,
+ owner_image: sheet.ownerImageUrl,
+ profile_url: sheet.ownerProfileUrl,
+ dateCreated: sheet.dateCreated,
+ _id: sheet.id,
+ snippet: sheet.summary || "",
+ }
+ })
+ }
+ const handleSheetsLoad = (sheets) => {
+ searchState.availableFilters = Sefaria.sheets.sheetsWithRefFilterNodes(sheets);
+ searchState.sortType = "views";
+ setSheets(sheets);
+ updateSearchState(searchState, 'sheet');
+ setOrigAvailableFilters(searchState.availableFilters);
+ setLoading(false);
+ }
+ const makeSheetsUnique = (sheets) => {
+ // if a sheet ID occurs in multiple sheet items, only keep the first sheet found so that there are not duplicates
+ return sheets.filter((sheet, index, self) =>
+ index === self.findIndex((s) => (
+ s.id === sheet.id)
+ ))
+ }
+
+ useEffect(() => {
+ Sefaria.sheets.getSheetsByRef(refs, makeSheetsUnique).then(sheets => {handleSheetsLoad(sheets);})
+ }, [refs]);
+
+ let sortedSheets = [...sheets];
+ sortedSheets = applyFilters(sortedSheets);
+ sortedSheets = applySortOption(sortedSheets);
+ sortedSheets = prepSheetsForDisplay(sortedSheets);
+ sortedSheets = normalizeSheetsMetaData(sortedSheets);
+ return
+}
+export default SheetsWithRefPage;
\ No newline at end of file
diff --git a/templates/_sidebar.html b/templates/_sidebar.html
index 6d6c02785d..0de08cb2b1 100644
--- a/templates/_sidebar.html
+++ b/templates/_sidebar.html
@@ -13,6 +13,9 @@ {{heTitle}}
Jobs at Sefaria
משרות פנויות בספריא
+
+ Sefaria's Products
+ מוצרים של ספריא
Our Supporters
התומכים שלנו
diff --git a/templates/static/products.html b/templates/static/products.html
new file mode 100644
index 0000000000..29828f2839
--- /dev/null
+++ b/templates/static/products.html
@@ -0,0 +1,30 @@
+
+{% extends "base.html" %}
+{% load i18n static %}
+
+{% block title %}{% trans "Products" %}{% endblock %}
+
+{% block description %}{% trans "A catalogue of Sefaria's products and experiments" %}{% endblock %}
+
+{% block js %}
+
+{% endblock %}
+
+{% block content %}
+
+ {% if not request.user_agent.is_mobile %}
+ {% include '_sidebar.html' with whichPage='products' title="Products" heTitle="מוצרים" %}
+ {% endif %}
+
+
+
+
+{% endblock %}
+{% block footer %} {% endblock %}