Skip to content

786-feat: Add merch page #882

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions dev-data/hero-page.data.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import coursesPageHeroImg from '@/shared/assets/mentor-with-his-students.webp';
import welcome from '@/shared/assets/welcome.webp';

export const heroPageData = {
school: {
Expand Down Expand Up @@ -26,4 +27,11 @@ export const heroPageData = {
subTitle: ['By teaching others, you learn yourself'],
imageAltText: '',
},
merch: {
mainTitle: 'Merch',
widgetTitle: 'Free assets for your design',
subTitle: [''],
heroImageSrc: welcome,
imageAltText: 'A sloth mascot with arms raised under a welcome sign',
},
};
1,869 changes: 1,307 additions & 562 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"dayjs": "^1.11.13",
"github-markdown-css": "^5.8.1",
"http-status": "^2.1.0",
"jszip": "^3.10.1",
"next": "15.3.1",
"pagefind": "^1.3.0",
"react": "19.1.0",
Expand Down
13 changes: 13 additions & 0 deletions src/app/merch/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Metadata } from 'next';

import { Merch } from '@/views/merch/merch';

export async function generateMetadata(): Promise<Metadata> {
const title = 'Merch Β· The Rolling Scopes School';

return { title };
}

export default function CommunityRoute() {
return <Merch />;
}
5 changes: 5 additions & 0 deletions src/core/api/app-api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MerchApi } from '@/entities/merch/api/merch-api';
import { TrainerApi } from '@/entities/trainer/api/trainer-api';
import { ApiBaseClass } from '@/shared/api/api-base-class';
import { ApiServices } from '@/shared/types';
Expand All @@ -7,9 +8,13 @@ export class Api {

public readonly trainer: TrainerApi;

public readonly merch: MerchApi;

constructor(private readonly baseURI: string) {
this.services = { rest: new ApiBaseClass(this.baseURI) };

this.trainer = new TrainerApi(this.services);

this.merch = new MerchApi(this.services);
}
}
1 change: 1 addition & 0 deletions src/core/styles/_constants.scss
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ $opacity-100: 1;
$opacity-80: 0.8;
$opacity-70: 0.7;
$opacity-50: 0.5;
$opacity-40: 0.4;
$opacity-30: 0.3;
$opacity-20: 0.2;
$opacity-15: 0.15;
Expand Down
10 changes: 10 additions & 0 deletions src/entities/merch/api/merch-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { MerchResponse } from '../types';
import { ApiServices } from '@/shared/types';

export class MerchApi {
constructor(private readonly services: ApiServices) {}

public queryMerchCatalog() {
return this.services.rest.get<MerchResponse>(`merch/filelist.json`);
}
}
45 changes: 45 additions & 0 deletions src/entities/merch/helpers/download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import JSZip from 'jszip';

function downloadFile(url: string, filename: string) {
const link = document.createElement('a');

link.href = url;
link.download = filename;
link.download = url.split('/').pop() || '';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
Comment on lines +3 to +12
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix duplicate download attribute assignment

There's a duplicate assignment to the download attribute which overwrites the provided filename.

  link.href = url;
  link.download = filename;
- link.download = url.split('/').pop() || '';

Alternatively, if you want to use the filename from the URL as a fallback:

  link.href = url;
- link.download = filename;
- link.download = url.split('/').pop() || '';
+ link.download = filename || url.split('/').pop() || '';
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function downloadFile(url: string, filename: string) {
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.download = url.split('/').pop() || '';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function downloadFile(url: string, filename: string) {
const link = document.createElement('a');
link.href = url;
link.download = filename || url.split('/').pop() || '';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}


function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);

downloadFile(url, filename);
}

async function createArchive(files: string[]): Promise<Blob> {
const zip = new JSZip();

await Promise.all(
files.map(async (url) => {
const response = await fetch(url);
const blob = await response.blob();
const filename = url.split('/').pop() || '';

zip.file(filename, blob);
}),
);

return zip.generateAsync({ type: 'blob' });
}
Comment on lines +20 to +34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion

Add error handling for fetch operations

The fetch operations could fail if URLs are invalid or network issues occur. Consider adding error handling.

  await Promise.all(
    files.map(async (url) => {
-     const response = await fetch(url);
-     const blob = await response.blob();
+     try {
+       const response = await fetch(url);
+       if (!response.ok) {
+         throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
+       }
+       const blob = await response.blob();
+       const filename = url.split('/').pop() || '';
+       zip.file(filename, blob);
+     } catch (error) {
+       console.error(`Error processing file ${url}:`, error);
+       // Optionally: throw error or add a placeholder file indicating the error
+     }
-     const filename = url.split('/').pop() || '';
-
-     zip.file(filename, blob);
    }),
  );
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function createArchive(files: string[]): Promise<Blob> {
const zip = new JSZip();
await Promise.all(
files.map(async (url) => {
const response = await fetch(url);
const blob = await response.blob();
const filename = url.split('/').pop() || '';
zip.file(filename, blob);
}),
);
return zip.generateAsync({ type: 'blob' });
}
async function createArchive(files: string[]): Promise<Blob> {
const zip = new JSZip();
await Promise.all(
files.map(async (url) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const filename = url.split('/').pop() || '';
zip.file(filename, blob);
} catch (error) {
console.error(`Error processing file ${url}:`, error);
// Optionally: throw error or add a placeholder file indicating the error
}
}),
);
return zip.generateAsync({ type: 'blob' });
}


export async function downloadArchive(files: string[], filename: string) {
if (files.length === 1) {
downloadFile(files[0], filename);
return;
}

const archiveBlob = await createArchive(files);

downloadBlob(archiveBlob, `${filename}.zip`);
}
36 changes: 36 additions & 0 deletions src/entities/merch/helpers/transform-merch-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ApiMerchItem, ApiMerchItemAdapt, MerchProduct, MerchResponse } from '../types';

export const transformMerchCatalog = (data: MerchResponse): MerchProduct[] => {
const products: MerchProduct[] = [];
const baseUrl = process.env.API_BASE_URL;
let index = 0;
Comment on lines +3 to +6
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion

Add fallback for API base URL

If process.env.API_BASE_URL is undefined, URLs will be invalid. Add a fallback value.

export const transformMerchCatalog = (data: MerchResponse): MerchProduct[] => {
  const products: MerchProduct[] = [];
- const baseUrl = process.env.API_BASE_URL;
+ const baseUrl = process.env.API_BASE_URL || '';
+ if (!baseUrl) {
+   console.warn('API_BASE_URL is not defined. URLs will be relative.');
+ }
  let index = 0;
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const transformMerchCatalog = (data: MerchResponse): MerchProduct[] => {
const products: MerchProduct[] = [];
const baseUrl = process.env.API_BASE_URL;
let index = 0;
export const transformMerchCatalog = (data: MerchResponse): MerchProduct[] => {
const products: MerchProduct[] = [];
const baseUrl = process.env.API_BASE_URL || '';
if (!baseUrl) {
console.warn('API_BASE_URL is not defined. URLs will be relative.');
}
let index = 0;

const processCategory = (category: ApiMerchItemAdapt, parentTags: string[]) => {
for (const [key, value] of Object.entries(category)) {
if (isApiMerchItem(value)) {
index += 1;
products.push({
id: index,
name: key,
title: value.name,
preview: value.preview.map((path) => `${baseUrl}/${path}`),
download: value.download.map((path) => `${baseUrl}/${path}`),
tags: parentTags,
});
} else {
processCategory(value, [...parentTags, key]);
}
}
};

for (const [categoryName, categoryData] of Object.entries(data)) {
processCategory(categoryData, [categoryName]);
}

return products;
};

const isApiMerchItem = (item: unknown): item is ApiMerchItem => {
return Boolean(
item && typeof item === 'object' && 'name' in item && 'preview' in item && 'download' in item,
);
};
3 changes: 3 additions & 0 deletions src/entities/merch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type { MerchProduct } from './types';
export { MerchCard } from './ui/merch-card/merch-card';
export { merchStore } from './model/store';
18 changes: 18 additions & 0 deletions src/entities/merch/model/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { transformMerchCatalog } from '../helpers/transform-merch-catalog';
import { api } from '@/shared/api/api';

class MerchStore {
public loadMerchCatalog = async () => {
try {
const res = await api.merch.queryMerchCatalog();

if (res.isSuccess) {
return transformMerchCatalog(res.result);
}
} catch (e) {
console.error(e);
}
};
Comment on lines +5 to +15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion

Improve error handling and add explicit return type.

The current implementation lacks proper error handling and type annotations. Also consider adding caching for repeated calls.

-  public loadMerchCatalog = async () => {
+  private cachedProducts = null;
+
+  public loadMerchCatalog = async (): Promise<Array<MerchProduct> | null> => {
+    if (this.cachedProducts) {
+      return this.cachedProducts;
+    }
+    
     try {
       const res = await api.merch.queryMerchCatalog();

       if (res.isSuccess) {
-        return transformMerchCatalog(res.result);
+        this.cachedProducts = transformMerchCatalog(res.result);
+        return this.cachedProducts;
       }
+      
+      return null;
     } catch (e) {
       console.error(e);
+      return null;
     }
   };
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public loadMerchCatalog = async () => {
try {
const res = await api.merch.queryMerchCatalog();
if (res.isSuccess) {
return transformMerchCatalog(res.result);
}
} catch (e) {
console.error(e);
}
};
private cachedProducts = null;
public loadMerchCatalog = async (): Promise<Array<MerchProduct> | null> => {
if (this.cachedProducts) {
return this.cachedProducts;
}
try {
const res = await api.merch.queryMerchCatalog();
if (res.isSuccess) {
this.cachedProducts = transformMerchCatalog(res.result);
return this.cachedProducts;
}
return null;
} catch (e) {
console.error(e);
return null;
}
};

}

export const merchStore = new MerchStore();
27 changes: 27 additions & 0 deletions src/entities/merch/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export type ApiMerchItem = {
name: string;
preview: string[];
download: string[];
};

type ApiMerchCategory = {
[key: string]: ApiMerchItem;
};

type ApiMerchData = {
[category: string]: ApiMerchCategory;
};

export type ApiMerchItemAdapt = ApiMerchItem | ApiMerchCategory | ApiMerchData;
export type MerchResponse = {
[category: string]: ApiMerchData;
};

export type MerchProduct = {
id: number;
name: string;
title: string;
preview: string[];
download: string[];
tags: string[];
};
98 changes: 98 additions & 0 deletions src/entities/merch/ui/merch-card/merch-card.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
.merch-card {
overflow: hidden;
display: flex;
flex-direction: column;

width: 100%;
max-width: 320px;
height: 100%;
margin: 0 auto;
border-radius: 5px;

background-color: $color-white;
box-shadow: 0 4px 12px 0 hsla(from $color-black h s l / $opacity-10);

transition: box-shadow 0.3s ease;

&:hover {
box-shadow:
0 1px 5px hsla(from $color-black h s l / $opacity-40),
0 2px 8px -8px hsla(from $color-black h s l / $opacity-40);
}
}

.preview-wrap {
position: relative;
display: flex;
height: 180px;

&::after {
pointer-events: none;
content: '';

position: absolute;
top: 0;
left: 0;

width: 100%;
height: 100%;

background: linear-gradient(
to bottom,
transparent 0%,
hsla(from $color-black h s l / $opacity-10) 100%
);
}
}

.info-wrap {
display: flex;
flex: 1 1;
gap: 10px;
align-items: center;
justify-content: space-between;

padding: 16px;
}

.preview {
object-fit: contain;
}

.image-container {
position: relative;
width: 100%;
height: 100%;
}

.download {
cursor: pointer;

position: absolute;
right: 10px;
bottom: 10px;

padding: 8px 10px;
border: none;
border-radius: 100%;

opacity: 0.8;
background-color: $color-yellow;
box-shadow: 0 4px 12px 0 hsla(from $color-black h s l / $opacity-10);

&:hover {
opacity: 1;
}

&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}

.download-img {
width: 20px;
height: 20px;
border-radius: 5px;
background-color: $color-yellow;
}
54 changes: 54 additions & 0 deletions src/entities/merch/ui/merch-card/merch-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use client';
import { useState } from 'react';
import classNames from 'classnames/bind';
import Image from 'next/image';

import { downloadArchive } from '../../helpers/download';
import { MerchProduct } from '@/entities/merch/types';
import downloadImg from '@/shared/assets/svg/download.svg';
import { Paragraph } from '@/shared/ui/paragraph';

import styles from './merch-card.module.scss';

export const cx = classNames.bind(styles);

export const MerchCard = ({ title, preview, download }: MerchProduct) => {
const [isLoading, setIsLoading] = useState(false);

const handleDownload = async () => {
if (isLoading) {
return;
}

try {
setIsLoading(true);
await downloadArchive(download, `${title}.zip`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ’‘ Verification agent

🧩 Analysis chain

Verify download array handling.

The code assumes download contains at least one URL, but doesn't handle empty arrays.


🏁 Script executed:

#!/bin/bash
# Check if there's validation for empty download arrays
rg -A 3 -B 3 "download.+\.length" --glob "*.{ts,tsx,js,jsx}"

Length of output: 62


🏁 Script executed:

#!/bin/bash
# Locate the definition and usages of downloadArchive to inspect empty-array handling
rg "downloadArchive" -n --glob "*.{ts,tsx,js,jsx}"

Length of output: 381


🏁 Script executed:

#!/bin/bash
# Show the downloadArchive function implementation to check for empty-array handling
rg -n -A 20 -B 5 "export async function downloadArchive" src/entities/merch/helpers/download.ts

Length of output: 456


Add empty-array guard before downloading

The downloadArchive helper only handles one or many filesβ€”passing an empty download array will fall through to createArchive and produce an empty (or malformed) ZIP. Please add a check for zero-length before invoking:

β€’ In src/entities/merch/ui/merch-card/merch-card.tsx:25, wrap the call:

if (download.length === 0) {
  // e.g. show β€œNo files to download” feedback
  return;
}
await downloadArchive(download, `${title}.zip`);

β€’ Optionally, in src/entities/merch/helpers/download.ts (at the top of downloadArchive), add:

if (files.length === 0) {
  throw new Error('No files to download');
}

} catch (error) {
console.error(error);
} finally {
Comment on lines +26 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion

Add user feedback for download errors.

The error is logged to console but there's no feedback to the user when the download fails.

try {
  setIsLoading(true);
  await downloadArchive(download, `${title}.zip`);
} catch (error) {
  console.error(error);
+  // Consider adding user-visible error notification here
+  // Example: toast.error('Failed to download. Please try again.');
} finally {
  setIsLoading(false);
}
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
console.error(error);
} finally {
} catch (error) {
console.error(error);
// Consider adding user-visible error notification here
// Example: toast.error('Failed to download. Please try again.');
} finally {

setIsLoading(false);
}
};

return (
<article className={cx('merch-card')} data-testid="merch">
<figure className={cx('preview-wrap')}>
<div className={cx('image-container')}>
<Image
className={cx('preview')}
src={preview[0]}
alt={title}
fill
sizes="(max-width: 320px) 100vw, 320px"
/>
</div>
<button onClick={handleDownload} className={cx('download')} disabled={isLoading}>
<Image src={downloadImg} alt="download link" className={cx('download-img')} />
</button>
Comment on lines +45 to +47
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion

Improve accessibility for the download button.

The button lacks accessible text and loading state indication.

<button 
  onClick={handleDownload} 
  className={cx('download')} 
  disabled={isLoading}
+  aria-label="Download merch files"
>
  <Image src={downloadImg} alt="download link" className={cx('download-img')} />
+  {isLoading && <span className="sr-only">Downloading...</span>}
</button>

</figure>
<div className={cx('info-wrap')}>
<Paragraph fontSize="medium">{title}</Paragraph>
</div>
</article>
);
};
20 changes: 20 additions & 0 deletions src/shared/assets/svg/download.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading