Skip to content

feat: pyconkr-shop API 호출을 위한 hook 추가 #1

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

Merged
merged 16 commits into from
May 1, 2025
Merged
Show file tree
Hide file tree
Changes from 13 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": "[email protected]+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808"
}
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
}
108 changes: 108 additions & 0 deletions package/pyconkr-shop/apis/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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, headers: { 'Content-Type': 'application/json' } })
}

_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)
167 changes: 167 additions & 0 deletions package/pyconkr-shop/apis/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { shopAPIClient } from "./client";

import ShopAPISchema from "@pyconkr-shop/schemas";

namespace ShopAPIRoute {
/**
* 로그인합니다.
* @param username - 사용자 이름
* @param password - 비밀번호
* @returns 로그인 정보
* @throws 401 - 로그인 정보가 없습니다.
*/
export const signInWithEmail = (data: ShopAPISchema.EmailSignInRequest) =>
shopAPIClient.post<
ShopAPISchema.UserStatus,
ShopAPISchema.EmailSignInRequest
>("authn/social/browser/v1/auth/login", data);

/**
* SNS로 로그인합니다.
* @param socialSignInInfo - SNS 로그인 정보
* @param socialSignInInfo.provider - SNS 제공자
* @param socialSignInInfo.callback_url - SNS 로그인 후 리다이렉트할 URL
* @returns 로그인 정보
*/
export const signInWithSNS = async (
socialSignInInfo: ShopAPISchema.SocialSignInRequest
) => {
const form = document.createElement("form");
form.method = "POST";
form.action = `${shopAPIClient.baseURL}/authn/social/browser/v1/auth/provider/redirect`;

Object.entries({
...socialSignInInfo,
csrfmiddlewaretoken: shopAPIClient.getCSRFToken() ?? "",
process: "login",
}).forEach(([key, value]) => {
const inputElement = document.createElement("input");
inputElement.type = "hidden";
inputElement.name = key;
inputElement.value = value;
form.appendChild(inputElement);
});
document.body.appendChild(form);
form.submit();
setTimeout(() => document.body.removeChild(form), 100);
};

/**
* 로그아웃합니다.
* @throws 401 - 로그아웃이 성공할 시에도 항상 401 에러가 발생합니다.
*/
export const signOut = () =>
shopAPIClient.delete<ShopAPISchema.UserStatus>(
"authn/social/browser/v1/auth/session"
);

/**
* 로그인 정보를 조회합니다.
* @returns 로그인 정보
* @throws 401 - 로그인 정보가 없습니다.
*/
export const retrieveUserInfo = () =>
shopAPIClient.get<ShopAPISchema.UserStatus>(
"authn/social/browser/v1/auth/session"
);

/**
* 노출 중인 모든 상품의 목록을 가져옵니다.
* @param qs - 상품 목록을 가져올 쿼리 파라미터
* @param qs.category_group - 상품 목록을 가져올 카테고리 그룹의 이름
* @param qs.category - 상품 목록을 가져올 카테고리의 이름
* @returns 노출 중인 모든 상품의 목록
*/
export const listProducts = (qs?: ShopAPISchema.ProductListQueryParams) =>
shopAPIClient.get<ShopAPISchema.Product[]>("v1/products/", { params: qs });

/**
* 현재 사용자의 장바구니에 담긴 상품의 목록을 가져옵니다.
* @returns 현재 장바구니 상태
*/
export const retrieveCart = () =>
shopAPIClient.get<ShopAPISchema.Order>("v1/orders/cart/");

/**
* 장바구니에 상품을 추가합니다.
* @param data 장바구니에 추가할 상품과 상품의 옵션 정보
* @param data.product 장바구니에 추가할 상품의 UUID
* @param data.options 장바구니에 추가할 상품의 옵션 정보
* @param data.options[].product_option_group 장바구니에 추가할 상품 옵션 그룹의 UUID
* @param data.options[].product_option 장바구니에 추가할 상품 옵션의 UUID (`null`일 경우 사용자 정의 응답)
* @param data.options[].custom_response 장바구니에 추가할 상품 옵션에 대한 사용자 정의 응답 (옵션 그룹이 사용자 정의 응답일 경우 필수)
* @returns 현재 장바구니 상태
*/
export const appendItemToCart = (data: ShopAPISchema.CartItemAppendRequest) =>
shopAPIClient.post<
ShopAPISchema.Order,
ShopAPISchema.CartItemAppendRequest
>("v1/orders/cart/products/", data);

/**
* 장바구니에서 특정 상품을 제거합니다.
* @param data 제거할 장바구니 내 상품의 UUID
* @returns 현재 장바구니 상태
*/
export const removeItemFromCart = (data: { cartProductId: string }) =>
shopAPIClient.delete<ShopAPISchema.Order>(
`v1/orders/cart/products/${data.cartProductId}/`
);

/**
* 단일 상품 즉시 결제를 PortOne에 등록합니다.
* @param data 결제할 상품과 상품의 옵션 정보
* @param data.product 결제할 상품의 UUID
* @param data.options 결제할 상품의 옵션 정보
* @param data.options[].product_option_group 결제할 상품 옵션 그룹의 UUID
* @param data.options[].product_option 결제할 상품 옵션의 UUID (`null`일 경우 사용자 정의 응답)
* @param data.options[].custom_response 결제할 상품 옵션에 대한 사용자 정의 응답 (옵션 그룹이 사용자 정의 응답일 경우 필수)
* @returns PortOne에 등록된 주문 정보
*/
export const prepareOneItemOrder = (
data: ShopAPISchema.OneItemOrderRequest
) =>
shopAPIClient.post<ShopAPISchema.Order, ShopAPISchema.OneItemOrderRequest>(
"v1/orders/single/",
data
);

/**
* 고객의 장바구니에 담긴 전체 상품 결제를 PortOne에 등록합니다.
* @returns PortOne에 등록된 주문 정보
*/
export const prepareCartOrder = () =>
shopAPIClient.post<ShopAPISchema.Order, undefined>(
"v1/orders/cart/",
undefined
);

/**
* 고객의 모든 결제 내역을 가져옵니다.
* @returns 고객의 모든 결제 내역
*/
export const listOrders = () =>
shopAPIClient.get<ShopAPISchema.Order[]>("v1/orders/");

/**
* 결제 완료된 주문 내역에서 특정 상품을 환불 시도합니다.
* @param data - 주문 내역 UUID와 주문 내역 내 환불할 상품 UUID
* @param data.order_id - 환불할 상품이 포함된 주문 내역의 UUID
* @param data.order_product_relation_id - 주문 내역 내 환불할 상품의 UUID
*/
export const refundOneItemFromOrder = (
data: ShopAPISchema.OneItemRefundRequest
) =>
shopAPIClient.delete<void>(
`v1/orders/${data.order_id}/products/${data.order_product_relation_id}/`
);

/**
* 결제 완료된 주문 내역을 환불 시도합니다.
* @param orderId - 환불할 주문 내역의 UUID
*/
export const refundAllItemsInOrder = (orderId: string) =>
shopAPIClient.delete<void>(`v1/orders/${orderId}/`);
}

export default ShopAPIRoute;
Loading