Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions dotenv/.env.development
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
VITE_PYCONKR_SHOP_API_DOMAIN=https://shop-api.dev.pycon.kr
VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME=DEBUG_csrftoken
2 changes: 2 additions & 0 deletions dotenv/.env.production
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
VITE_PYCONKR_SHOP_API_DOMAIN=https://shop-api.pycon.kr
VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME=csrftoken
16 changes: 14 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,41 @@
"@mdx-js/react": "^3.1.0",
"@mdx-js/rollup": "^3.1.0",
"@mui/material": "^7.0.2",
"@pyconkr-common": "link:package/pyconkr-common",
"@pyconkr-shop": "link:package/pyconkr-shop",
"@src": "link:src",
"@suspensive/react": "^2.18.12",
"@tanstack/react-query": "^5.72.2",
"axios": "^1.8.4",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"notistack": "^3.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"remeda": "^2.21.3"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@tanstack/react-query-devtools": "^5.74.4",
"@types/node": "^22.14.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@typescript-eslint/parser": "^8.29.1",
"@vitejs/plugin-react": "^4.3.4",
"csstype": "^3.1.3",
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"gh-pages": "^6.3.0",
"globals": "^15.15.0",
"iamport-typings": "^1.4.0",
"prettier": "^3.5.3",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0",
"vite-plugin-mdx": "^3.6.1"
}
},
"packageManager": "pnpm@10.7.1+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808"
}
26 changes: 26 additions & 0 deletions package/pyconkr-common/components/mdx.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from 'react';
import * as runtime from "react/jsx-runtime";

import { evaluate } from "@mdx-js/mdx";
import { MDXProvider } from "@mdx-js/react";
import { CircularProgress, Typography } from '@mui/material';
import { wrap } from '@suspensive/react';
import { useSuspenseQuery } from "@tanstack/react-query";

const useMDX = (text: string) => useSuspenseQuery({
queryKey: ['mdx', text],
queryFn: async () => {
const { default: RenderResult } = await evaluate(text, { ...runtime, baseUrl: import.meta.url });
return <MDXProvider><RenderResult /></MDXProvider>
}
})

export const MDXRenderer: React.FC<{ text: string }> = wrap
.ErrorBoundary({
fallback: ({ error }) => {
console.error('MDX 변환 오류:', error);
return <Typography variant="body2" color="error">MDX 변환 오류: {error.message}</Typography>
}
})
.Suspense({ fallback: <CircularProgress /> })
.on(({ text }) => useMDX(text).data);
5 changes: 5 additions & 0 deletions package/pyconkr-common/components/price_display.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React from 'react';

export const PriceDisplay: React.FC<{ price: number, label?: string }> = ({ price, label }) => {
return <>{(label ? `${label} : ` : '') + price.toLocaleString()}원</>
};
14 changes: 14 additions & 0 deletions package/pyconkr-common/utils/cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as R from 'remeda'

export const getCookie = (name: string) => {
if (!R.isString(document.cookie) || R.isEmpty(document.cookie))
return undefined

let cookieValue: string | undefined
document.cookie.split(';').forEach((cookie) => {
if (R.isEmpty(cookie) || !cookie.includes('=')) return
const [key, value] = cookie.split('=', 2)
if (key.trim() === name) cookieValue = decodeURIComponent(value) as string
})
return cookieValue
}
40 changes: 40 additions & 0 deletions package/pyconkr-common/utils/form.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as R from 'remeda'

export type PossibleFormInputType = HTMLFormElement | undefined | null
export type FormResultObject = { [k: string]: FormDataEntryValue | boolean | null }

export const isFormValid = (form: HTMLFormElement | null | undefined): form is HTMLFormElement => {
if (!(R.isObjectType(form) && form instanceof HTMLFormElement)) return false

if (!form.checkValidity()) {
form.reportValidity()
return false
}

return true
}

export function getFormValue<T>(_: { form: HTMLFormElement; fieldToExcludeWhenFalse?: string[]; fieldToNullWhenFalse?: string[] }): T {
const formData: {
[k: string]: FormDataEntryValue | boolean | null
} = Object.fromEntries(new FormData(_.form))
Object.keys(formData)
.filter((key) => (_.fieldToExcludeWhenFalse ?? []).includes(key) || (_.fieldToNullWhenFalse ?? []).includes(key))
.filter((key) => R.isEmpty(formData[key] as string))
.forEach((key) => {
if ((_.fieldToExcludeWhenFalse ?? []).includes(key)) {
delete formData[key]
} else if ((_.fieldToNullWhenFalse ?? []).includes(key)) {
formData[key] = null
}
})
Array.from(_.form.children).forEach((child) => {
const targetElement: Element | null = child
if (targetElement && !(targetElement instanceof HTMLInputElement)) {
const targetElements = targetElement.querySelectorAll('input')
for (const target of targetElements)
if (target instanceof HTMLInputElement && target.type === 'checkbox') formData[target.name] = target.checked ? true : false
}
})
return formData as T
}
206 changes: 206 additions & 0 deletions package/pyconkr-shop/apis/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import * as R from "remeda";

import { getCookie } from "@pyconkr-common/utils/cookie";
import ShopAPISchema, {
isObjectErrorResponseSchema,
} from "@pyconkr-shop/schemas";

const DEFAULT_TIMEOUT = 10000;
const DEFAULT_ERROR_MESSAGE =
"알 수 없는 문제가 발생했습니다, 잠시 후 다시 시도해주세요.";
const DEFAULT_ERROR_RESPONSE = {
type: "unknown",
errors: [{ code: "unknown", detail: DEFAULT_ERROR_MESSAGE, attr: null }],
};

export class ShopAPIClientError extends Error {
readonly name = "ShopAPIError";
readonly status: number;
readonly detail: ShopAPISchema.ErrorResponseSchema;
readonly originalError: unknown;

constructor(error?: unknown) {
let message: string = DEFAULT_ERROR_MESSAGE;
let detail: ShopAPISchema.ErrorResponseSchema = DEFAULT_ERROR_RESPONSE;
let status = -1;

if (axios.isAxiosError(error)) {
const response = error.response;

if (response) {
status = response.status;
detail = isObjectErrorResponseSchema(response.data)
? response.data
: {
type: "axios_error",
errors: [
{
code: "unknown",
detail: R.isString(response.data)
? response.data
: DEFAULT_ERROR_MESSAGE,
attr: null,
},
],
};
}
} else if (error instanceof Error) {
message = error.message;
detail = {
type: error.name || typeof error || "unknown",
errors: [{ code: "unknown", detail: error.message, attr: null }],
};
}

super(message);
this.originalError = error || null;
this.status = status;
this.detail = detail;
}

isRequiredAuth(): boolean {
return this.status === 401 || this.status === 403;
}
}

type AxiosRequestWithoutPayload = <T = any, R = AxiosResponse<T>, D = any>(
url: string,
config?: AxiosRequestConfig<D>
) => Promise<R>;
type AxiosRequestWithPayload = <T = any, R = AxiosResponse<T>, D = any>(
url: string,
data?: D,
config?: AxiosRequestConfig<D>
) => Promise<R>;

class ShopAPIClient {
readonly baseURL: string;
protected readonly csrfCookieName: string;
private readonly shopAPI: AxiosInstance;

constructor(
baseURL: string = import.meta.env.VITE_PYCONKR_SHOP_API_DOMAIN,
csrfCookieName: string = import.meta.env.VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME,
timeout: number = DEFAULT_TIMEOUT
) {
this.baseURL = baseURL;
this.csrfCookieName = csrfCookieName;
this.shopAPI = axios.create({
baseURL,
timeout,
withCredentials: true,
headers: { "Content-Type": "application/json" },
});
this.shopAPI.interceptors.request.use(
(config) => {
config.headers["x-csrftoken"] = this.getCSRFToken();
return config;
},
(error) => Promise.reject(error)
);
}

_safe_request_without_payload(
requestFunc: AxiosRequestWithoutPayload
): AxiosRequestWithoutPayload {
return async <T = any, R = AxiosResponse<T>, D = any>(
url: string,
config?: AxiosRequestConfig<D>
) => {
try {
return await requestFunc<T, R, D>(url, config);
} catch (error) {
throw new ShopAPIClientError(error);
}
};
}

_safe_request_with_payload(
requestFunc: AxiosRequestWithPayload
): AxiosRequestWithPayload {
return async <T = any, R = AxiosResponse<T>, D = any>(
url: string,
data: D,
config?: AxiosRequestConfig<D>
) => {
try {
return await requestFunc<T, R, D>(url, data, config);
} catch (error) {
throw new ShopAPIClientError(error);
}
};
}

getCSRFToken(): string | undefined {
return getCookie(this.csrfCookieName);
}

async get<T, D = any>(
url: string,
config?: AxiosRequestConfig<D>
): Promise<T> {
return (
await this._safe_request_without_payload(this.shopAPI.get)<
T,
AxiosResponse<T>,
D
>(url, config)
).data;
}
async post<T, D>(
url: string,
data: D,
config?: AxiosRequestConfig<D>
): Promise<T> {
return (
await this._safe_request_with_payload(this.shopAPI.post)<
T,
AxiosResponse<T>,
D
>(url, data, config)
).data;
}
async put<T, D>(
url: string,
data: D,
config?: AxiosRequestConfig<D>
): Promise<T> {
return (
await this._safe_request_with_payload(this.shopAPI.put)<
T,
AxiosResponse<T>,
D
>(url, data, config)
).data;
}
async patch<T, D>(
url: string,
data: D,
config?: AxiosRequestConfig<D>
): Promise<T> {
return (
await this._safe_request_with_payload(this.shopAPI.patch)<
T,
AxiosResponse<T>,
D
>(url, data, config)
).data;
}
async delete<T, D = any>(
url: string,
config?: AxiosRequestConfig<D>
): Promise<T> {
return (
await this._safe_request_without_payload(this.shopAPI.delete)<
T,
AxiosResponse<T>,
D
>(url, config)
).data;
}
}

export const shopAPIClient = new ShopAPIClient(
import.meta.env.VITE_PYCONKR_SHOP_API_DOMAIN
);
Loading