-
-
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
d6a751a
4ad14a4
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 MerchRoute() { | ||
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,16 @@ | ||
import { transformMerchCatalog } from '../helpers/transform-merch-catalog'; | ||
import { api } from '@/shared/api/api'; | ||
|
||
class MerchStore { | ||
public loadMerchCatalog = async () => { | ||
const res = await api.merch.queryMerchCatalog(); | ||
|
||
if (res.isSuccess) { | ||
return transformMerchCatalog(res.result); | ||
} | ||
|
||
throw new Error('Error while loading merch catalog.'); | ||
}; | ||
} | ||
|
||
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; | ||
Quiddlee marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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,50 @@ | ||
'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; | ||
} | ||
|
||
setIsLoading(true); | ||
await downloadArchive(download, `${title}.zip`); | ||
|
||
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
+41
to
+43
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