Skip to content

Commit 06032c2

Browse files
authored
Merge pull request #1 from pythonkr/feature/add-shop-api-hook
feat: pyconkr-shop API 호출을 위한 hook 추가
2 parents da7edff + d940057 commit 06032c2

File tree

16 files changed

+1083
-13
lines changed

16 files changed

+1083
-13
lines changed

dotenv/.env.development

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
VITE_PYCONKR_SHOP_API_DOMAIN=https://shop-api.dev.pycon.kr
2+
VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME=DEBUG_csrftoken

dotenv/.env.production

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
VITE_PYCONKR_SHOP_API_DOMAIN=https://shop-api.pycon.kr
2+
VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME=csrftoken

package.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,29 +20,41 @@
2020
"@mdx-js/react": "^3.1.0",
2121
"@mdx-js/rollup": "^3.1.0",
2222
"@mui/material": "^7.0.2",
23+
"@pyconkr-common": "link:package/pyconkr-common",
24+
"@pyconkr-shop": "link:package/pyconkr-shop",
25+
"@src": "link:src",
26+
"@suspensive/react": "^2.18.12",
2327
"@tanstack/react-query": "^5.72.2",
28+
"axios": "^1.8.4",
2429
"eslint-plugin-import": "^2.31.0",
2530
"eslint-plugin-jsx-a11y": "^6.10.2",
31+
"notistack": "^3.0.2",
2632
"react": "^19.0.0",
27-
"react-dom": "^19.0.0"
33+
"react-dom": "^19.0.0",
34+
"remeda": "^2.21.3"
2835
},
2936
"devDependencies": {
3037
"@eslint/js": "^9.21.0",
38+
"@tanstack/react-query-devtools": "^5.74.4",
39+
"@types/node": "^22.14.1",
3140
"@types/react": "^19.0.10",
3241
"@types/react-dom": "^19.0.4",
3342
"@typescript-eslint/parser": "^8.29.1",
3443
"@vitejs/plugin-react": "^4.3.4",
44+
"csstype": "^3.1.3",
3545
"eslint": "^9.21.0",
3646
"eslint-config-prettier": "^10.1.2",
3747
"eslint-plugin-prettier": "^5.2.6",
3848
"eslint-plugin-react-hooks": "^5.1.0",
3949
"eslint-plugin-react-refresh": "^0.4.19",
4050
"gh-pages": "^6.3.0",
4151
"globals": "^15.15.0",
52+
"iamport-typings": "^1.4.0",
4253
"prettier": "^3.5.3",
4354
"typescript": "~5.7.2",
4455
"typescript-eslint": "^8.24.1",
4556
"vite": "^6.2.0",
4657
"vite-plugin-mdx": "^3.6.1"
47-
}
58+
},
59+
"packageManager": "[email protected]+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808"
4860
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import React from 'react';
2+
import * as runtime from "react/jsx-runtime";
3+
4+
import { evaluate } from "@mdx-js/mdx";
5+
import { MDXProvider } from "@mdx-js/react";
6+
import { CircularProgress, Typography } from '@mui/material';
7+
import { wrap } from '@suspensive/react';
8+
import { useSuspenseQuery } from "@tanstack/react-query";
9+
10+
const useMDX = (text: string) => useSuspenseQuery({
11+
queryKey: ['mdx', text],
12+
queryFn: async () => {
13+
const { default: RenderResult } = await evaluate(text, { ...runtime, baseUrl: import.meta.url });
14+
return <MDXProvider><RenderResult /></MDXProvider>
15+
}
16+
})
17+
18+
export const MDXRenderer: React.FC<{ text: string }> = wrap
19+
.ErrorBoundary({
20+
fallback: ({ error }) => {
21+
console.error('MDX 변환 오류:', error);
22+
return <Typography variant="body2" color="error">MDX 변환 오류: {error.message}</Typography>
23+
}
24+
})
25+
.Suspense({ fallback: <CircularProgress /> })
26+
.on(({ text }) => useMDX(text).data);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import React from 'react';
2+
3+
export const PriceDisplay: React.FC<{ price: number, label?: string }> = ({ price, label }) => {
4+
return <>{(label ? `${label} : ` : '') + price.toLocaleString()}</>
5+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as R from 'remeda'
2+
3+
export const getCookie = (name: string) => {
4+
if (!R.isString(document.cookie) || R.isEmpty(document.cookie))
5+
return undefined
6+
7+
let cookieValue: string | undefined
8+
document.cookie.split(';').forEach((cookie) => {
9+
if (R.isEmpty(cookie) || !cookie.includes('=')) return
10+
const [key, value] = cookie.split('=', 2)
11+
if (key.trim() === name) cookieValue = decodeURIComponent(value) as string
12+
})
13+
return cookieValue
14+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import * as R from 'remeda'
2+
3+
export type PossibleFormInputType = HTMLFormElement | undefined | null
4+
export type FormResultObject = { [k: string]: FormDataEntryValue | boolean | null }
5+
6+
export const isFormValid = (form: HTMLFormElement | null | undefined): form is HTMLFormElement => {
7+
if (!(R.isObjectType(form) && form instanceof HTMLFormElement)) return false
8+
9+
if (!form.checkValidity()) {
10+
form.reportValidity()
11+
return false
12+
}
13+
14+
return true
15+
}
16+
17+
export function getFormValue<T>(_: { form: HTMLFormElement; fieldToExcludeWhenFalse?: string[]; fieldToNullWhenFalse?: string[] }): T {
18+
const formData: {
19+
[k: string]: FormDataEntryValue | boolean | null
20+
} = Object.fromEntries(new FormData(_.form))
21+
Object.keys(formData)
22+
.filter((key) => (_.fieldToExcludeWhenFalse ?? []).includes(key) || (_.fieldToNullWhenFalse ?? []).includes(key))
23+
.filter((key) => R.isEmpty(formData[key] as string))
24+
.forEach((key) => {
25+
if ((_.fieldToExcludeWhenFalse ?? []).includes(key)) {
26+
delete formData[key]
27+
} else if ((_.fieldToNullWhenFalse ?? []).includes(key)) {
28+
formData[key] = null
29+
}
30+
})
31+
Array.from(_.form.children).forEach((child) => {
32+
const targetElement: Element | null = child
33+
if (targetElement && !(targetElement instanceof HTMLInputElement)) {
34+
const targetElements = targetElement.querySelectorAll('input')
35+
for (const target of targetElements)
36+
if (target instanceof HTMLInputElement && target.type === 'checkbox') formData[target.name] = target.checked ? true : false
37+
}
38+
})
39+
return formData as T
40+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
2+
import * as R from "remeda";
3+
4+
import { getCookie } from "@pyconkr-common/utils/cookie";
5+
import ShopAPISchema, {
6+
isObjectErrorResponseSchema,
7+
} from "@pyconkr-shop/schemas";
8+
9+
const DEFAULT_TIMEOUT = 10000;
10+
const DEFAULT_ERROR_MESSAGE =
11+
"알 수 없는 문제가 발생했습니다, 잠시 후 다시 시도해주세요.";
12+
const DEFAULT_ERROR_RESPONSE = {
13+
type: "unknown",
14+
errors: [{ code: "unknown", detail: DEFAULT_ERROR_MESSAGE, attr: null }],
15+
};
16+
17+
export class ShopAPIClientError extends Error {
18+
readonly name = "ShopAPIError";
19+
readonly status: number;
20+
readonly detail: ShopAPISchema.ErrorResponseSchema;
21+
readonly originalError: unknown;
22+
23+
constructor(error?: unknown) {
24+
let message: string = DEFAULT_ERROR_MESSAGE;
25+
let detail: ShopAPISchema.ErrorResponseSchema = DEFAULT_ERROR_RESPONSE;
26+
let status = -1;
27+
28+
if (axios.isAxiosError(error)) {
29+
const response = error.response;
30+
31+
if (response) {
32+
status = response.status;
33+
detail = isObjectErrorResponseSchema(response.data)
34+
? response.data
35+
: {
36+
type: "axios_error",
37+
errors: [
38+
{
39+
code: "unknown",
40+
detail: R.isString(response.data)
41+
? response.data
42+
: DEFAULT_ERROR_MESSAGE,
43+
attr: null,
44+
},
45+
],
46+
};
47+
}
48+
} else if (error instanceof Error) {
49+
message = error.message;
50+
detail = {
51+
type: error.name || typeof error || "unknown",
52+
errors: [{ code: "unknown", detail: error.message, attr: null }],
53+
};
54+
}
55+
56+
super(message);
57+
this.originalError = error || null;
58+
this.status = status;
59+
this.detail = detail;
60+
}
61+
62+
isRequiredAuth(): boolean {
63+
return this.status === 401 || this.status === 403;
64+
}
65+
}
66+
67+
type AxiosRequestWithoutPayload = <T = any, R = AxiosResponse<T>, D = any>(
68+
url: string,
69+
config?: AxiosRequestConfig<D>
70+
) => Promise<R>;
71+
type AxiosRequestWithPayload = <T = any, R = AxiosResponse<T>, D = any>(
72+
url: string,
73+
data?: D,
74+
config?: AxiosRequestConfig<D>
75+
) => Promise<R>;
76+
77+
class ShopAPIClient {
78+
readonly baseURL: string;
79+
protected readonly csrfCookieName: string;
80+
private readonly shopAPI: AxiosInstance;
81+
82+
constructor(
83+
baseURL: string = import.meta.env.VITE_PYCONKR_SHOP_API_DOMAIN,
84+
csrfCookieName: string = import.meta.env.VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME,
85+
timeout: number = DEFAULT_TIMEOUT
86+
) {
87+
this.baseURL = baseURL;
88+
this.csrfCookieName = csrfCookieName;
89+
this.shopAPI = axios.create({
90+
baseURL,
91+
timeout,
92+
withCredentials: true,
93+
headers: { "Content-Type": "application/json" },
94+
});
95+
this.shopAPI.interceptors.request.use(
96+
(config) => {
97+
config.headers["x-csrftoken"] = this.getCSRFToken();
98+
return config;
99+
},
100+
(error) => Promise.reject(error)
101+
);
102+
}
103+
104+
_safe_request_without_payload(
105+
requestFunc: AxiosRequestWithoutPayload
106+
): AxiosRequestWithoutPayload {
107+
return async <T = any, R = AxiosResponse<T>, D = any>(
108+
url: string,
109+
config?: AxiosRequestConfig<D>
110+
) => {
111+
try {
112+
return await requestFunc<T, R, D>(url, config);
113+
} catch (error) {
114+
throw new ShopAPIClientError(error);
115+
}
116+
};
117+
}
118+
119+
_safe_request_with_payload(
120+
requestFunc: AxiosRequestWithPayload
121+
): AxiosRequestWithPayload {
122+
return async <T = any, R = AxiosResponse<T>, D = any>(
123+
url: string,
124+
data: D,
125+
config?: AxiosRequestConfig<D>
126+
) => {
127+
try {
128+
return await requestFunc<T, R, D>(url, data, config);
129+
} catch (error) {
130+
throw new ShopAPIClientError(error);
131+
}
132+
};
133+
}
134+
135+
getCSRFToken(): string | undefined {
136+
return getCookie(this.csrfCookieName);
137+
}
138+
139+
async get<T, D = any>(
140+
url: string,
141+
config?: AxiosRequestConfig<D>
142+
): Promise<T> {
143+
return (
144+
await this._safe_request_without_payload(this.shopAPI.get)<
145+
T,
146+
AxiosResponse<T>,
147+
D
148+
>(url, config)
149+
).data;
150+
}
151+
async post<T, D>(
152+
url: string,
153+
data: D,
154+
config?: AxiosRequestConfig<D>
155+
): Promise<T> {
156+
return (
157+
await this._safe_request_with_payload(this.shopAPI.post)<
158+
T,
159+
AxiosResponse<T>,
160+
D
161+
>(url, data, config)
162+
).data;
163+
}
164+
async put<T, D>(
165+
url: string,
166+
data: D,
167+
config?: AxiosRequestConfig<D>
168+
): Promise<T> {
169+
return (
170+
await this._safe_request_with_payload(this.shopAPI.put)<
171+
T,
172+
AxiosResponse<T>,
173+
D
174+
>(url, data, config)
175+
).data;
176+
}
177+
async patch<T, D>(
178+
url: string,
179+
data: D,
180+
config?: AxiosRequestConfig<D>
181+
): Promise<T> {
182+
return (
183+
await this._safe_request_with_payload(this.shopAPI.patch)<
184+
T,
185+
AxiosResponse<T>,
186+
D
187+
>(url, data, config)
188+
).data;
189+
}
190+
async delete<T, D = any>(
191+
url: string,
192+
config?: AxiosRequestConfig<D>
193+
): Promise<T> {
194+
return (
195+
await this._safe_request_without_payload(this.shopAPI.delete)<
196+
T,
197+
AxiosResponse<T>,
198+
D
199+
>(url, config)
200+
).data;
201+
}
202+
}
203+
204+
export const shopAPIClient = new ShopAPIClient(
205+
import.meta.env.VITE_PYCONKR_SHOP_API_DOMAIN
206+
);

0 commit comments

Comments
 (0)