-
-
Notifications
You must be signed in to change notification settings - Fork 11
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
base: main
Are you sure you want to change the base?
Changes from all commits
e993b26
2e66950
9eafcbe
109650d
0647f7b
bacb488
ed4c779
ef8479a
098bc07
b647fbc
3cacc8f
451f808
eb3e9b0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
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 />; | ||
} |
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`); | ||
} | ||
} |
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); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π οΈ Refactor suggestion Add fallback for API base URL If 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
Suggested change
|
||||||||||||||||||||||||
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, | ||||||||||||||||||||||||
); | ||||||||||||||||||||||||
}; |
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'; |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
export const merchStore = new MerchStore(); |
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[]; | ||
}; |
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; | ||
} |
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`); | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π‘ Verification agent π§© Analysis chainVerify 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 β’ 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||||||||||||||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||||||||||||||||||
); | ||||||||||||||||||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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:
π Committable suggestion