diff --git a/static/js/store/components/SearchInput/SearchInput.tsx b/static/js/store/components/SearchInput/SearchInput.tsx index 451dd15545..02f562d751 100644 --- a/static/js/store/components/SearchInput/SearchInput.tsx +++ b/static/js/store/components/SearchInput/SearchInput.tsx @@ -1,5 +1,5 @@ import { Button } from "@canonical/react-components"; -import { useSearchParams } from "react-router-dom"; +import { useSearchParams, useNavigate, useLocation } from "react-router-dom"; import type { RefObject } from "react"; @@ -13,9 +13,16 @@ export const SearchInput = ({ searchSummaryRef, }: Props): React.JSX.Element => { const [searchParams, setSearchParams] = useSearchParams(); + const { pathname } = useLocation(); + const navigate = useNavigate(); const onSubmit = (event: React.FormEvent): void => { event.preventDefault(); + + if (pathname !== "/store") { + navigate("/store"); + } + if (searchRef?.current && searchRef.current.value) { searchParams.delete("page"); searchParams.set("q", searchRef.current.value); @@ -43,7 +50,7 @@ export const SearchInput = ({ id="search" className="p-search-box__input" name="q" - placeholder="Search Snapcraft" + placeholder="Search snap store" defaultValue={searchParams.get("q") || ""} ref={searchRef} /> diff --git a/static/js/store/hooks/index.ts b/static/js/store/hooks/index.ts index 706a032359..5e94f61b15 100644 --- a/static/js/store/hooks/index.ts +++ b/static/js/store/hooks/index.ts @@ -1,3 +1,4 @@ import usePackages from "./usePackages"; +import useCategories from "./useCategories"; -export { usePackages }; +export { usePackages, useCategories }; diff --git a/static/js/store/hooks/useCategories.ts b/static/js/store/hooks/useCategories.ts new file mode 100644 index 0000000000..5d94d9b251 --- /dev/null +++ b/static/js/store/hooks/useCategories.ts @@ -0,0 +1,20 @@ +import { useQuery } from "react-query"; + +function useCategories() { + return useQuery({ + queryKey: ["categories"], + queryFn: async () => { + const response = await fetch("/store.json"); + + if (!response.ok) { + throw new Error("There was a problem fetching categories"); + } + + const responseData = await response.json(); + + return responseData.categories; + }, + }); +} + +export default useCategories; diff --git a/static/js/store/index.tsx b/static/js/store/index.tsx index 67ed09be1e..ff7eb757ce 100644 --- a/static/js/store/index.tsx +++ b/static/js/store/index.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "react-query"; import Root from "./layouts/Root"; import Store from "./pages/Store"; +import Explore from "./pages/Explore"; const router = createBrowserRouter([ { @@ -14,6 +15,10 @@ const router = createBrowserRouter([ path: "/store", element: , }, + { + path: "/explore", + element: , + }, ], }, ]); diff --git a/static/js/store/pages/Explore/CardsLoader.tsx b/static/js/store/pages/Explore/CardsLoader.tsx new file mode 100644 index 0000000000..5593d06624 --- /dev/null +++ b/static/js/store/pages/Explore/CardsLoader.tsx @@ -0,0 +1,28 @@ +import { Row, Col } from "@canonical/react-components"; +import { LoadingCard } from "@canonical/store-components"; +import { v4 as uuidv4 } from "uuid"; + +const elementIds: string[] = Array.of( + uuidv4(), + uuidv4(), + uuidv4(), + uuidv4(), + uuidv4(), + uuidv4(), + uuidv4(), + uuidv4(), +); + +function CardsLoader(): JSX.Element { + return ( + + {elementIds.map((id) => ( + + + + ))} + + ); +} + +export default CardsLoader; diff --git a/static/js/store/pages/Explore/Categories.tsx b/static/js/store/pages/Explore/Categories.tsx new file mode 100644 index 0000000000..477c982206 --- /dev/null +++ b/static/js/store/pages/Explore/Categories.tsx @@ -0,0 +1,35 @@ +import { Row, Col, Strip } from "@canonical/react-components"; + +import { useCategories } from "../../hooks"; + +import type { Category } from "../../types"; + +function Categories(): JSX.Element { + const { data, isLoading } = useCategories(); + + return ( + <> +

Categories

+ {!isLoading && data && ( + + + {data.map((category: Category) => ( + +

+ + {category.display_name} + +

+ + ))} +
+
+ )} + + ); +} + +export default Categories; diff --git a/static/js/store/pages/Explore/EditorialSection.tsx b/static/js/store/pages/Explore/EditorialSection.tsx new file mode 100644 index 0000000000..e8b93bb296 --- /dev/null +++ b/static/js/store/pages/Explore/EditorialSection.tsx @@ -0,0 +1,56 @@ +import { Row, Col } from "@canonical/react-components"; +import { SlicesData } from "../../types"; + +type Props = { + isLoading: boolean; + slice: SlicesData; + gradient: "blueGreen" | "purplePink"; +}; + +function EditorialSection({ isLoading, slice, gradient }: Props): JSX.Element { + const gradients: Record = { + blueGreen: "linear-gradient(45deg, #251755 20%, #69e07c)", + purplePink: "linear-gradient(45deg, #19224d 20%, #c481d1)", + }; + + return ( +
+
+ {!isLoading && ( +
+ + +
+

{slice.slice.name}

+

{slice.slice.description}

+
+ + + {slice.snaps.slice(0, 3).map((snap) => ( +
+ + {snap.title} + +
+ ))} + +
+
+ )} +
+
+ ); +} + +export default EditorialSection; diff --git a/static/js/store/pages/Explore/Explore.tsx b/static/js/store/pages/Explore/Explore.tsx new file mode 100644 index 0000000000..04ff8dd309 --- /dev/null +++ b/static/js/store/pages/Explore/Explore.tsx @@ -0,0 +1,191 @@ +import { useRef } from "react"; +import { useQueries } from "react-query"; +import { Strip, Row, Col } from "@canonical/react-components"; + +import Banner from "../../components/Banner"; +import RecommendationsSection from "./RecommendationsSection"; +// import EditorialSection from "./EditorialSection"; +import ListSection from "./ListSection"; +import Categories from "./Categories"; + +import type { UseQueryResult } from "react-query"; +import type { RecommendationData } from "../../types"; + +function Explore(): JSX.Element { + const searchRef = useRef(null); + const searchSummaryRef = useRef(null); + const categories: string[] = ["popular", "recent", "trending"]; + // const sliceIds: string[] = ["our_picks", "must_have_snaps"]; + + // const slices = useQueries( + // sliceIds.map((sliceId) => ({ + // queryKey: ["slices", sliceId], + // queryFn: async () => { + // const response = await fetch( + // `https://recommendations.snapcraft.io/api/slice/${sliceId}`, + // ); + // + // if (!response.ok) { + // throw Error(`Unable to fetch ${sliceId} data`); + // } + // + // const responseData = await response.json(); + // + // return responseData; + // }, + // })), + // ); + + // const slicesLoading: boolean = slices.some((s) => s.isLoading); + + // const slicesData: Record = {}; + + // if (slices) { + // slices.forEach((slice) => { + // if (slice.data) { + // slicesData[slice.data.slice.id] = slice.data; + // } + // }); + // } + + const recommendations: UseQueryResult<{ + name: string; + snaps: RecommendationData[]; + }>[] = useQueries( + categories.map((category) => ({ + queryKey: ["recommendations", category], + queryFn: async () => { + const response = await fetch( + `https://recommendations.snapcraft.io/api/category/${category}`, + ); + + if (!response.ok) { + throw Error(`Unable to fetch ${category} data`); + } + + const responseData = await response.json(); + + return { + name: category, + snaps: responseData, + }; + }, + })), + ); + + const recommendationsLoading: boolean = recommendations.some( + (r) => r.isLoading, + ); + + const snaps: Record = {}; + + if (recommendations) { + recommendations.forEach((recommendation) => { + if (recommendation.data) { + snaps[recommendation.data.name] = recommendation.data.snaps.slice(0, 8); + } + }); + } + + return ( + <> + + + {/* slices && ( + <> + {slicesData.our_picks && ( + + + + )} + + ) */} + + {recommendations && ( + <> + {snaps.recent && ( + + + + )} + + {snaps.popular && ( + + + + )} + + )} + + + + + + {/* Placeholder until content is decided */} + {/* slices && ( + <> + {slicesData.must_have_snaps && ( + + + + )} + + ) */} + + {recommendations && snaps.trending && ( + + + + )} + + +
+ + +

Learn how to snap in 30 minutes

+

+ Find out how to build and publish snaps +

+ + Get started + + +
+
+
+ + ); +} + +export default Explore; diff --git a/static/js/store/pages/Explore/ListSection.tsx b/static/js/store/pages/Explore/ListSection.tsx new file mode 100644 index 0000000000..4a02520b09 --- /dev/null +++ b/static/js/store/pages/Explore/ListSection.tsx @@ -0,0 +1,82 @@ +import { Row, Col, Strip } from "@canonical/react-components"; + +import CardsLoader from "./CardsLoader"; + +import type { RecommendationData } from "../../types"; + +type Props = { + isLoading: boolean; + snaps: RecommendationData[]; + title: string; +}; + +function ListSection({ isLoading, snaps, title }: Props): JSX.Element { + return ( + <> +
+

{title}

+
+ + {isLoading && } + + {!isLoading && ( + + + {snaps.map((item: RecommendationData, index: number) => ( + +

+ {index + 1} +

+
+ + {item.details.title} + +
+

+ {item.details.title} +

+

+ {item.details.publisher} + {item.details.developer_validation === "verified" ? ( + <> + {" "} + Verified account + + ) : null} +

+
+
+ + ))} +
+
+ )} + + ); +} + +export default ListSection; diff --git a/static/js/store/pages/Explore/RecommendationsSection.tsx b/static/js/store/pages/Explore/RecommendationsSection.tsx new file mode 100644 index 0000000000..3757eb2a39 --- /dev/null +++ b/static/js/store/pages/Explore/RecommendationsSection.tsx @@ -0,0 +1,54 @@ +import { Row, Col, Strip } from "@canonical/react-components"; +import { DefaultCard } from "@canonical/store-components"; + +import CardsLoader from "./CardsLoader"; + +import { formatCardData } from "../../utils"; + +import type { RecommendationData } from "../../types"; + +type Props = { + isLoading: boolean; + snaps: RecommendationData[]; + title: string; + highlight?: boolean; +}; + +function RecommendationsSection({ + isLoading, + snaps, + title, + highlight, +}: Props): JSX.Element { + return ( + <> +
+

{title}

+
+ + {isLoading && } + + {!isLoading && ( + + + {snaps.map((item: RecommendationData) => ( + + + + ))} + + + )} + + ); +} + +export default RecommendationsSection; diff --git a/static/js/store/pages/Explore/__tests__/Categories.test.tsx b/static/js/store/pages/Explore/__tests__/Categories.test.tsx new file mode 100644 index 0000000000..68984eb2f2 --- /dev/null +++ b/static/js/store/pages/Explore/__tests__/Categories.test.tsx @@ -0,0 +1,64 @@ +import { BrowserRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; + +import Categories from "../Categories"; + +const queryClient = new QueryClient(); + +function renderComponent() { + return render( + + + + + , + ); +} + +const handlers = [ + http.get("/store.json", () => { + return HttpResponse.json({ + categories: [ + { name: "test-category-1", display_name: "Test Category 1" }, + { name: "test-category-2", display_name: "Test Category 2" }, + ], + }); + }), +]; + +const server = setupServer(...handlers); + +beforeAll(() => { + server.listen(); +}); + +afterEach(() => { + server.resetHandlers(); + queryClient.clear(); +}); + +afterAll(() => { + server.close(); +}); + +describe("Categories", () => { + test("renders category title", async () => { + renderComponent(); + await waitFor(() => { + expect(screen.getByText("Test Category 1")).toBeInTheDocument(); + }); + }); + + test("renders category link", async () => { + renderComponent(); + await waitFor(() => { + expect( + screen.getByRole("link", { name: "Test Category 1" }), + ).toHaveAttribute("href", "/store?categories=test-category-1&page=1"); + }); + }); +}); diff --git a/static/js/store/pages/Explore/__tests__/EditorialSection.test.tsx b/static/js/store/pages/Explore/__tests__/EditorialSection.test.tsx new file mode 100644 index 0000000000..7c04df86d0 --- /dev/null +++ b/static/js/store/pages/Explore/__tests__/EditorialSection.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; + +import EditorialSection from "../EditorialSection"; + +function renderComponent() { + return render( + , + ); +} + +describe("EditorialSection", () => { + test("displays title", () => { + renderComponent(); + expect( + screen.getByRole("heading", { level: 2, name: "Test slice name" }), + ).toBeInTheDocument(); + }); + + test("displays description", () => { + renderComponent(); + expect(screen.getByText("Test slice description")).toBeInTheDocument(); + }); + + test("displays icon", () => { + renderComponent(); + expect(screen.getByAltText("Test snap 2")).toBeInTheDocument(); + }); +}); diff --git a/static/js/store/pages/Explore/__tests__/Explore.test.tsx b/static/js/store/pages/Explore/__tests__/Explore.test.tsx new file mode 100644 index 0000000000..488842522d --- /dev/null +++ b/static/js/store/pages/Explore/__tests__/Explore.test.tsx @@ -0,0 +1,96 @@ +import { BrowserRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; + +import Explore from "../Explore"; + +import { mockRecommendations } from "../../../test-utils"; + +const queryClient = new QueryClient(); + +function renderComponent() { + return render( + + + + + , + ); +} + +const handlers = [ + http.get("https://recommendations.snapcraft.io/api/category/popular", () => { + return HttpResponse.json(mockRecommendations); + }), + http.get("https://recommendations.snapcraft.io/api/category/recent", () => { + return HttpResponse.json(mockRecommendations); + }), + http.get("https://recommendations.snapcraft.io/api/category/trending", () => { + return HttpResponse.json(mockRecommendations); + }), +]; + +const server = setupServer(...handlers); + +beforeAll(() => { + server.listen(); +}); + +afterEach(() => { + server.resetHandlers(); + queryClient.clear(); +}); + +afterAll(() => { + server.close(); +}); + +describe("Explore", () => { + test("renders hero", () => { + renderComponent(); + expect( + screen.getByRole("heading", { + level: 1, + name: "The app store for Linux", + }), + ).toBeInTheDocument(); + }); + + test("renders search", () => { + renderComponent(); + expect(screen.getByLabelText("Search Snapcraft")).toBeInTheDocument(); + }); + + test("renders updated snaps", async () => { + renderComponent(); + await waitFor(() => { + expect( + screen.getByRole("heading", { + level: 2, + name: "Recently updated snaps", + }), + ).toBeInTheDocument(); + }); + }); + + test("renders popular snaps", async () => { + renderComponent(); + await waitFor(() => { + expect( + screen.getByRole("heading", { level: 2, name: "Most popular snaps" }), + ).toBeInTheDocument(); + }); + }); + + test("renders trending snaps", async () => { + renderComponent(); + await waitFor(() => { + expect( + screen.getByRole("heading", { level: 2, name: "Trending snaps" }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/static/js/store/pages/Explore/__tests__/ListSection.test.tsx b/static/js/store/pages/Explore/__tests__/ListSection.test.tsx new file mode 100644 index 0000000000..c3116102f9 --- /dev/null +++ b/static/js/store/pages/Explore/__tests__/ListSection.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; + +import ListSection from "../ListSection"; + +import { mockRecommendations } from "../../../test-utils"; + +function renderComponent(isLoading?: boolean) { + return render( + , + ); +} +describe("ListSection", () => { + test("displays title if loading", () => { + renderComponent(true); + expect( + screen.getByRole("heading", { level: 2, name: "Section title" }), + ).toBeInTheDocument(); + }); + + test("displays title if not loading", () => { + renderComponent(); + expect( + screen.getByRole("heading", { level: 2, name: "Section title" }), + ).toBeInTheDocument(); + }); + + test("renders the correct number of cards", () => { + renderComponent(); + expect( + screen.getAllByRole("heading", { level: 3, name: /Test snap title/ }), + ).toHaveLength(6); + }); +}); diff --git a/static/js/store/pages/Explore/__tests__/RecommendationsSection.test.tsx b/static/js/store/pages/Explore/__tests__/RecommendationsSection.test.tsx new file mode 100644 index 0000000000..f8f149bcc0 --- /dev/null +++ b/static/js/store/pages/Explore/__tests__/RecommendationsSection.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; + +import RecommendationsSection from "../RecommendationsSection"; + +import { mockRecommendations } from "../../../test-utils"; + +function renderComponent(isLoading?: boolean) { + return render( + , + ); +} +describe("RecommendationsSection", () => { + test("displays title if loading", () => { + renderComponent(true); + expect( + screen.getByRole("heading", { level: 2, name: "Section title" }), + ).toBeInTheDocument(); + }); + + test("displays title if not loading", () => { + renderComponent(); + expect( + screen.getByRole("heading", { level: 2, name: "Section title" }), + ).toBeInTheDocument(); + }); + + test("renders the correct number of cards", () => { + renderComponent(); + expect( + screen.getAllByRole("heading", { level: 2, name: /Test snap title/ }), + ).toHaveLength(6); + }); +}); diff --git a/static/js/store/pages/Explore/index.ts b/static/js/store/pages/Explore/index.ts new file mode 100644 index 0000000000..ee4055b2ca --- /dev/null +++ b/static/js/store/pages/Explore/index.ts @@ -0,0 +1 @@ +export { default } from "./Explore"; diff --git a/static/js/store/test-utils/index.ts b/static/js/store/test-utils/index.ts index 6149a4d579..d5621262d6 100644 --- a/static/js/store/test-utils/index.ts +++ b/static/js/store/test-utils/index.ts @@ -1,3 +1,4 @@ import { testPackageData } from "./testPackageData"; +import mockRecommendations from "./mockRecommendations"; -export { testPackageData }; +export { mockRecommendations, testPackageData }; diff --git a/static/js/store/test-utils/mockRecommendations.ts b/static/js/store/test-utils/mockRecommendations.ts new file mode 100644 index 0000000000..ea0a0e5b20 --- /dev/null +++ b/static/js/store/test-utils/mockRecommendations.ts @@ -0,0 +1,68 @@ +export default [ + { + details: { + developer_validation: "starred", + icon: "https://example.com/icon.png", + snap_id: "test-snap-id-1", + name: "test-snap-name-1", + publisher: "Canonical", + summary: "Test snap summary 1", + title: "Test snap title 1", + }, + }, + { + details: { + developer_validation: "starred", + icon: "https://example.com/icon.png", + snap_id: "test-snap-id-2", + name: "test-snap-name-2", + publisher: "Canonical", + summary: "Test snap summary 2", + title: "Test snap title 2", + }, + }, + { + details: { + developer_validation: "starred", + icon: "https://example.com/icon.png", + snap_id: "test-snap-id-3", + name: "test-snap-name-3", + publisher: "Canonical", + summary: "Test snap summary 3", + title: "Test snap title 3", + }, + }, + { + details: { + developer_validation: "starred", + icon: "https://example.com/icon.png", + snap_id: "test-snap-id-4", + name: "test-snap-name-4", + publisher: "Canonical", + summary: "Test snap summary 4", + title: "Test snap title 4", + }, + }, + { + details: { + developer_validation: "starred", + icon: "https://example.com/icon.png", + snap_id: "test-snap-id-5", + name: "test-snap-name-5", + publisher: "Canonical", + summary: "Test snap summary 5", + title: "Test snap title 5", + }, + }, + { + details: { + developer_validation: "starred", + icon: "https://example.com/icon.png", + snap_id: "test-snap-id-6", + name: "test-snap-name-6", + publisher: "Canonical", + summary: "Test snap summary 6", + title: "Test snap title 6", + }, + }, +]; diff --git a/static/js/store/types/CardData.ts b/static/js/store/types/CardData.ts new file mode 100644 index 0000000000..5d26d57a8a --- /dev/null +++ b/static/js/store/types/CardData.ts @@ -0,0 +1,13 @@ +export type CardData = { + package: { + description: string; + display_name: string; + icon_url: string; + name: string; + }; + publisher: { + display_name: string; + name: string; + validation: string; + }; +}; diff --git a/static/js/store/types/RecommendationData.ts b/static/js/store/types/RecommendationData.ts new file mode 100644 index 0000000000..f9242be563 --- /dev/null +++ b/static/js/store/types/RecommendationData.ts @@ -0,0 +1,11 @@ +export type RecommendationData = { + details: { + developer_validation: string; + icon: string; + snap_id: string; + name: string; + publisher: string; + summary: string; + title: string; + }; +}; diff --git a/static/js/store/types/SlicesData.ts b/static/js/store/types/SlicesData.ts new file mode 100644 index 0000000000..3cd6b3a612 --- /dev/null +++ b/static/js/store/types/SlicesData.ts @@ -0,0 +1,4 @@ +export type SlicesData = { + slice: { description: string; id: string; name: string }; + snaps: { icon: string; name: string; title: string }[]; +}; diff --git a/static/js/store/types/index.ts b/static/js/store/types/index.ts index 8100e4ab56..457ec95b48 100644 --- a/static/js/store/types/index.ts +++ b/static/js/store/types/index.ts @@ -1,5 +1,15 @@ +import type { CardData } from "./CardData"; import type { Category } from "./Category"; import type { Package } from "./Package"; import type { Packages } from "./Packages"; +import type { RecommendationData } from "./RecommendationData"; +import type { SlicesData } from "./SlicesData"; -export type { Category, Package, Packages }; +export type { + CardData, + Category, + Package, + Packages, + RecommendationData, + SlicesData, +}; diff --git a/static/js/store/utils/__tests__/formatCardData.test.ts b/static/js/store/utils/__tests__/formatCardData.test.ts new file mode 100644 index 0000000000..2f90e6129c --- /dev/null +++ b/static/js/store/utils/__tests__/formatCardData.test.ts @@ -0,0 +1,31 @@ +import formatCardData from "../formatCardData"; + +const mockRecommendationData = { + details: { + developer_validation: "starred", + icon: "https://example.com/icon.png", + snap_id: "test-snap-id", + name: "test-snap-name", + publisher: "Canonical", + summary: "Test snap summary", + title: "Test snap title", + }, +}; + +describe("formatCardData", () => { + test("formats recommendation data into card data format", () => { + expect(formatCardData(mockRecommendationData)).toEqual({ + package: { + description: "Test snap summary", + display_name: "Test snap title", + icon_url: "https://example.com/icon.png", + name: "test-snap-name", + }, + publisher: { + display_name: "Canonical", + name: "Canonical", + validation: "starred", + }, + }); + }); +}); diff --git a/static/js/store/utils/formatCardData.ts b/static/js/store/utils/formatCardData.ts new file mode 100644 index 0000000000..8afbaad5aa --- /dev/null +++ b/static/js/store/utils/formatCardData.ts @@ -0,0 +1,19 @@ +import type { CardData, RecommendationData } from "../types"; + +function formatCardData(data: RecommendationData): CardData { + return { + package: { + description: data.details.summary, + display_name: data.details.title, + icon_url: data.details.icon, + name: data.details.name, + }, + publisher: { + display_name: data.details.publisher, + name: data.details.publisher, + validation: data.details.developer_validation, + }, + }; +} + +export default formatCardData; diff --git a/static/js/store/utils/index.ts b/static/js/store/utils/index.ts index ca4ae99675..d7b175d6e8 100644 --- a/static/js/store/utils/index.ts +++ b/static/js/store/utils/index.ts @@ -1,4 +1,5 @@ +import formatCardData from "./formatCardData"; import getArchitectures from "./getArchitectures"; import getCategoryOrder from "./getCategoryOrder"; -export { getArchitectures, getCategoryOrder }; +export { formatCardData, getArchitectures, getCategoryOrder }; diff --git a/static/sass/styles.scss b/static/sass/styles.scss index 62cc6b7482..e489d1aacc 100644 --- a/static/sass/styles.scss +++ b/static/sass/styles.scss @@ -511,10 +511,48 @@ html { position: relative; } +.slice-banner { + padding: 1.5rem; + + @media screen and (min-width: $breakpoint-small) { + padding: 74px 78px; + } +} + +.slice-icons { + display: flex; + justify-content: flex-start; + + @media screen and (min-width: $breakpoint-large) { + justify-content: flex-end; + } +} + +.slice-icon { + background-color: #fff; + border-radius: 5px; + display: inline-block; + margin-right: 18px; + padding: 0.5rem; + + @media screen and (min-width: $breakpoint-small) { + padding: 1rem; + } + + @media screen and (min-width: $breakpoint-large) { + padding: 24px; + } + + &:last-child { + margin-right: 0; + } +} + .read-only-dark { color: $colors--theme--text-muted; } +// Needed so the tooltip from Joyride in the tour works in dark mode .__floater__arrow { polygon { fill: $colors--theme--background-default; diff --git a/webapp/handlers.py b/webapp/handlers.py index 80ddda3e8a..150bc897e4 100644 --- a/webapp/handlers.py +++ b/webapp/handlers.py @@ -89,6 +89,7 @@ "*.crazyegg.com", "www.facebook.com", "px.ads.linkedin.com", + "*.snapcraft.io", ], "frame-src": [ "'self'", diff --git a/webapp/store/views.py b/webapp/store/views.py index 1dae40715e..205e530bfe 100644 --- a/webapp/store/views.py +++ b/webapp/store/views.py @@ -118,6 +118,10 @@ def brand_search_snap(): def store_view(): return flask.render_template("store/store.html") + @store.route("/explore") + def explore_view(): + return flask.render_template("store/store.html") + @store.route("/youtube", methods=["POST"]) def get_video_thumbnail_data(): body = flask.request.form @@ -343,6 +347,8 @@ def sitemap(): else: store.add_url_rule("/store", "homepage", store_view) + store.add_url_rule("/explore", "explore", explore_view) + @store.route("//create-track", methods=["POST"]) @login_required @csrf.exempt