diff --git a/dotenv/.env.development b/dotenv/.env.development index f1e20bf..1f5fdf5 100644 --- a/dotenv/.env.development +++ b/dotenv/.env.development @@ -1,2 +1,3 @@ VITE_PYCONKR_SHOP_API_DOMAIN=https://shop-api.dev.pycon.kr VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME=DEBUG_csrftoken +VITE_PYCONKR_SHOP_IMP_ACCOUNT_ID=imp80859147 diff --git a/dotenv/.env.production b/dotenv/.env.production index aee9515..a4b0532 100644 --- a/dotenv/.env.production +++ b/dotenv/.env.production @@ -1,2 +1,3 @@ VITE_PYCONKR_SHOP_API_DOMAIN=https://shop-api.pycon.kr VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME=csrftoken +VITE_PYCONKR_SHOP_IMP_ACCOUNT_ID=imp96676915 diff --git a/index.html b/index.html index e4b78ea..759e7ee 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,46 @@ - - - - Vite + React + TS + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PyCon Korea 2025
diff --git a/package.json b/package.json index 5933f99..07661b6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@mdx-js/mdx": "^3.1.0", "@mdx-js/react": "^3.1.0", "@mdx-js/rollup": "^3.1.0", + "@mui/icons-material": "^7.1.0", "@mui/material": "^7.0.2", "@pyconkr-common": "link:package/pyconkr-common", "@pyconkr-shop": "link:package/pyconkr-shop", @@ -28,10 +29,12 @@ "axios": "^1.8.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", + "globals": "^15.15.0", "notistack": "^3.0.2", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.5.3" + "react-router-dom": "^7.5.3", + "remeda": "^2.21.3" }, "devDependencies": { @@ -49,14 +52,13 @@ "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", - "remeda": "^2.21.3", "typescript": "~5.7.2", "typescript-eslint": "^8.24.1", "vite": "^6.2.0", "vite-plugin-mdx": "^3.6.1", + "vite-plugin-mkcert": "^1.17.8", "vite-plugin-svgr": "^4.3.0" }, diff --git a/package/pyconkr-shop/apis/index.ts b/package/pyconkr-shop/apis/index.ts index 1458645..eb4e0c8 100644 --- a/package/pyconkr-shop/apis/index.ts +++ b/package/pyconkr-shop/apis/index.ts @@ -86,7 +86,7 @@ namespace ShopAPIRoute { * @returns 현재 장바구니 상태 */ export const retrieveCart = () => - shopAPIClient.get("v1/orders/cart/"); + shopAPIClient.get("v1/orders/cart/"); /** * 장바구니에 상품을 추가합니다. @@ -138,7 +138,7 @@ namespace ShopAPIRoute { */ export const prepareCartOrder = () => shopAPIClient.post( - "v1/orders/cart/", + "v1/orders/", undefined ); @@ -168,6 +168,20 @@ namespace ShopAPIRoute { */ export const refundAllItemsInOrder = (data: { order_id: string }) => shopAPIClient.delete(`v1/orders/${data.order_id}/`); + + /** + * 결제 완료된 주문의 사용자 정의 응답용 상품 옵션을 수정합니다. + * @param data.order_id - 수정할 주문 내역의 UUID + * @param data.order_product_relation_id - 수정할 주문 내역 내 상품의 UUID + * @param data.options - 수정할 상품 옵션 정보 + * @param data.options[].order_product_option_relation - 수정할 상품 옵션의 UUID + * @param data.options[].custom_response - 수정할 상품 옵션에 대한 사용자 정의 응답 + */ + export const patchOrderOptions = async (data: ShopAPISchema.OrderOptionsPatchRequest) => + shopAPIClient.patch( + `v1/orders/${data.order_id}/products/${data.order_product_relation_id}/options/`, + data.options + ); } export default ShopAPIRoute; diff --git a/package/pyconkr-shop/components/index.ts b/package/pyconkr-shop/components/index.ts new file mode 100644 index 0000000..2c9b089 --- /dev/null +++ b/package/pyconkr-shop/components/index.ts @@ -0,0 +1,15 @@ +import { + OptionGroupInput as OptionGroupInputComponent, + OrderProductRelationOptionInput as OrderProductRelationOptionInputComponent, +} from "./option_group_input"; +import { PriceDisplay as PriceDisplayComponent } from "./price_display"; +import { ShopSignInGuard as ShopSignInGuardComponent } from "./signin_guard"; + +namespace ShopComponent { + export const OptionGroupInput = OptionGroupInputComponent; + export const OrderProductRelationOptionInput = OrderProductRelationOptionInputComponent; + export const PriceDisplay = PriceDisplayComponent; + export const ShopSignInGuard = ShopSignInGuardComponent; +} + +export default ShopComponent; diff --git a/package/pyconkr-shop/components/option_group_input.tsx b/package/pyconkr-shop/components/option_group_input.tsx new file mode 100644 index 0000000..bdbf15d --- /dev/null +++ b/package/pyconkr-shop/components/option_group_input.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import * as R from 'remeda'; + +import { FormControl, InputLabel, MenuItem, Select, TextField, Tooltip } from '@mui/material'; + +import ShopAPISchema from '@pyconkr-shop/schemas'; +import ShopAPIUtil from '@pyconkr-shop/utils'; +import { PriceDisplay } from './price_display'; + +type CommonOptionGroupType = { + id: string; + name: string; +} +type SelectableOptionGroupType = CommonOptionGroupType & { + is_custom_response: false; + custom_response_pattern: null; +} +type CustomResponseOptionGroupType = CommonOptionGroupType & { + is_custom_response: true; + custom_response_pattern: string; +} +type OptionGroupType = SelectableOptionGroupType | CustomResponseOptionGroupType; + +type SimplifiedOption = Pick; + +const isFilledString = (str: unknown): str is string => R.isString(str) && !R.isEmpty(str); + +const SelectableOptionGroupInput: React.FC<{ + optionGroup: SelectableOptionGroupType; + options: SimplifiedOption[]; + defaultValue?: string; + disabled?: boolean; + disabledReason?: string; +}> = ({ optionGroup, options, defaultValue, disabled, disabledReason }) => { + const optionElements = options.map( + (option) => { + const isOptionOutOfStock = R.isNumber(option.leftover_stock) && option.leftover_stock <= 0; + + return + {option.name} + {option.additional_price > 0 && (<> [ + ])} + {isOptionOutOfStock && <> (품절)} + + } + ) + + const selectElement = + {optionGroup.name} + + + + return isFilledString(disabledReason) ? {selectElement} : selectElement +} + +const CustomResponseOptionGroupInput: React.FC<{ + optionGroup: CustomResponseOptionGroupType; + defaultValue?: string; + disabled?: boolean; + disabledReason?: string; +}> = ({ optionGroup, defaultValue, disabled, disabledReason }) => { + const pattern = ShopAPIUtil.getCustomResponsePattern(optionGroup)?.source; + + const textFieldElement = + + return isFilledString(disabledReason) ? {textFieldElement} : textFieldElement +} + +export const OptionGroupInput: React.FC<{ + optionGroup: OptionGroupType; + options: SimplifiedOption[]; + + defaultValue?: string; + disabled?: boolean; + disabledReason?: string; +}> = ({ optionGroup, options, defaultValue, disabled, disabledReason }) => optionGroup.is_custom_response + ? + : + + +export const OrderProductRelationOptionInput: React.FC<{ + optionRel: ShopAPISchema.OrderProductItem["options"][number]; + disabled?: boolean; + disabledReason?: string; +}> = ({ optionRel, disabled, disabledReason }) => { + let defaultValue: string | null = null; + let guessedDisabledReason: string | undefined = undefined; + let dummyOptions: { id: string; name: string; additional_price: number; leftover_stock: number | null }[] = []; + + // type hinting을 위해 if문을 사용함 + if (optionRel.product_option_group.is_custom_response === false && R.isNonNull(optionRel.product_option)) { + defaultValue = optionRel.product_option.id; + guessedDisabledReason = '추가 비용이 발생하는 옵션은 수정할 수 없어요.'; + dummyOptions = [ + { + id: optionRel.product_option.id, + name: optionRel.product_option.name, + additional_price: optionRel.product_option.additional_price || 0, + leftover_stock: null, + } + ] + } else { + defaultValue = optionRel.custom_response; + } + + return +} diff --git a/package/pyconkr-common/components/price_display.tsx b/package/pyconkr-shop/components/price_display.tsx similarity index 100% rename from package/pyconkr-common/components/price_display.tsx rename to package/pyconkr-shop/components/price_display.tsx diff --git a/package/pyconkr-shop/components/signin_guard.tsx b/package/pyconkr-shop/components/signin_guard.tsx new file mode 100644 index 0000000..9e0c753 --- /dev/null +++ b/package/pyconkr-shop/components/signin_guard.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { Typography } from '@mui/material'; + +import ShopAPIHook from '@pyconkr-shop/hooks'; + +export const ShopSignInGuard: React.FC<{ children: React.ReactNode, fallback?: React.ReactNode }> = ({ children, fallback }) => { + const { data } = ShopAPIHook.useUserStatus(); + const renderedFallback = fallback || 로그인 후 이용해주세요.; + return (data && data.meta.is_authenticated === true) ? children : renderedFallback; +}; diff --git a/package/pyconkr-shop/hooks/index.ts b/package/pyconkr-shop/hooks/index.ts index 7811186..82a5cc5 100644 --- a/package/pyconkr-shop/hooks/index.ts +++ b/package/pyconkr-shop/hooks/index.ts @@ -140,6 +140,13 @@ namespace ShopAPIHook { mutationFn: ShopAPIRoute.refundAllItemsInOrder, meta: { invalidates: [QUERY_KEYS.ORDER_LIST] }, }); + + export const useOptionsOfOneItemInOrderPatchMutation = () => + useMutation({ + mutationKey: MUTATION_KEYS.CART_ITEM_APPEND, + mutationFn: ShopAPIRoute.patchOrderOptions, + meta: { invalidates: [QUERY_KEYS.ORDER_LIST] }, + }); } export default ShopAPIHook; diff --git a/package/pyconkr-shop/schemas/index.ts b/package/pyconkr-shop/schemas/index.ts index 32e5350..13409b0 100644 --- a/package/pyconkr-shop/schemas/index.ts +++ b/package/pyconkr-shop/schemas/index.ts @@ -73,18 +73,25 @@ namespace ShopAPISchema { name: string; additional_price: number; max_quantity_per_user: number; - leftover_stock: number; + leftover_stock: number | null; }; export type OptionGroup = { id: string; name: string; + min_quantity_per_product: number; max_quantity_per_product: number; - is_custom_response: boolean; - custom_response_pattern: string | null; options: Option[]; - }; + } & ( + { + is_custom_response: false; + custom_response_pattern: null; + } | { + is_custom_response: true; + custom_response_pattern: string; + } + ); export type ProductListQueryParams = { category_group?: string; @@ -124,28 +131,43 @@ namespace ShopAPISchema { export type OrderProductItem = { id: string; - status: PaymentHistoryStatus; + status: OrderProductItemStatus; price: number; additional_price: number; + not_refundable_reason: string | null; product: { id: string; name: string; price: number; image: string | null; }; - options: { - product_option_group: { - id: string; - name: string; - is_custom_response: boolean; - }; - product_option: { - id: string; - name: string; - additional_price: number; - } | null; - custom_response: string | null; - }[]; + options: ( + { + product_option_group: { + id: string; + name: string; + is_custom_response: false; + custom_response_pattern: null; + response_modifiable_ends_at: string | null; + } + product_option: { + id: string; + name: string; + additional_price: number; + }; + custom_response: null; + } | { + product_option_group: { + id: string; + name: string; + is_custom_response: true; + custom_response_pattern: string; + response_modifiable_ends_at: string | null; + } + product_option: null; + custom_response: string; + } + )[]; }; export type Order = { @@ -154,6 +176,7 @@ namespace ShopAPISchema { first_paid_price: number; current_paid_price: number; current_status: PaymentHistoryStatus; + not_fully_refundable_reason: string | null; created_at: string; payment_histories: PaymentHistory[]; @@ -175,6 +198,15 @@ namespace ShopAPISchema { order_id: string; order_product_relation_id: string; }; + + export type OrderOptionsPatchRequest = { + order_id: string + order_product_relation_id: string; + options: { + order_product_option_relation: string + custom_response: string + }[] + }; } export const isObjectErrorResponseSchema = ( diff --git a/package/pyconkr-shop/utils/index.ts b/package/pyconkr-shop/utils/index.ts new file mode 100644 index 0000000..73aa413 --- /dev/null +++ b/package/pyconkr-shop/utils/index.ts @@ -0,0 +1,26 @@ +import * as R from "remeda"; + +import ShopAPISchema from "@pyconkr-shop/schemas"; +import { startPortOnePurchase as _startPortOnePurchase } from './portone'; + +namespace ShopAPIUtil { + export const getCustomResponsePattern = (optionGroup: Pick) => { + const pattern = optionGroup.custom_response_pattern?.trim() ?? ""; + return R.isString(pattern) && !R.isEmpty(pattern) ? new RegExp(pattern, "g") : undefined; + }; + + export const isOrderProductOptionModifiable = (optionRel: ShopAPISchema.OrderProductItem["options"][number]): boolean => { + if (!optionRel.product_option_group.is_custom_response) return false; + + if (R.isNullish(optionRel.product_option_group.response_modifiable_ends_at)) + return true; + else if (new Date() <= new Date(optionRel.product_option_group.response_modifiable_ends_at)) + return true; + + return false; + } + + export const startPortOnePurchase = _startPortOnePurchase; +}; + +export default ShopAPIUtil; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae2a479..1bc3f88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@mdx-js/rollup': specifier: ^3.1.0 version: 3.1.0(acorn@8.14.1)(rollup@4.39.0) + '@mui/icons-material': + specifier: ^7.1.0 + version: 7.1.0(@mui/material@7.0.2(@emotion/react@11.14.0(@types/react@19.1.1)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.1)(react@19.1.0))(@types/react@19.1.1)(react@19.1.0))(@types/react@19.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.1)(react@19.1.0) '@mui/material': specifier: ^7.0.2 version: 7.0.2(@emotion/react@11.14.0(@types/react@19.1.1)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.1)(react@19.1.0))(@types/react@19.1.1)(react@19.1.0))(@types/react@19.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -43,13 +46,16 @@ importers: version: 5.72.2(react@19.1.0) axios: specifier: ^1.8.4 - version: 1.8.4 + version: 1.8.4(debug@4.4.0) eslint-plugin-import: specifier: ^2.31.0 version: 2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.24.0)(typescript@5.7.3))(eslint@9.24.0) eslint-plugin-jsx-a11y: specifier: ^6.10.2 version: 6.10.2(eslint@9.24.0) + globals: + specifier: ^15.15.0 + version: 15.15.0 notistack: specifier: ^3.0.2 version: 3.0.2(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -69,12 +75,12 @@ importers: '@eslint/js': specifier: ^9.21.0 version: 9.24.0 - '@types/node': - specifier: ^22.15.3 - version: 22.15.3 '@tanstack/react-query-devtools': specifier: ^5.74.4 version: 5.74.4(@tanstack/react-query@5.72.2(react@19.1.0))(react@19.1.0) + '@types/node': + specifier: ^22.15.3 + version: 22.15.3 '@types/react': specifier: ^19.0.10 version: 19.1.1 @@ -87,6 +93,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.3.4(vite@6.2.6(@types/node@22.15.3)) + csstype: + specifier: ^3.1.3 + version: 3.1.3 eslint: specifier: ^9.21.0 version: 9.24.0 @@ -105,9 +114,6 @@ importers: gh-pages: specifier: ^6.3.0 version: 6.3.0 - globals: - specifier: ^15.15.0 - version: 15.15.0 iamport-typings: specifier: ^1.4.0 version: 1.4.0 @@ -126,6 +132,9 @@ importers: vite-plugin-mdx: specifier: ^3.6.1 version: 3.6.1(@mdx-js/mdx@3.1.0(acorn@8.14.1))(vite@6.2.6(@types/node@22.15.3)) + vite-plugin-mkcert: + specifier: ^1.17.8 + version: 1.17.8(vite@6.2.6(@types/node@22.15.3)) vite-plugin-svgr: specifier: ^4.3.0 version: 4.3.0(rollup@4.39.0)(typescript@5.7.3)(vite@6.2.6(@types/node@22.15.3)) @@ -211,6 +220,10 @@ packages: resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.27.1': + resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.0': resolution: {integrity: sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==} engines: {node: '>=6.9.0'} @@ -524,6 +537,17 @@ packages: '@mui/core-downloads-tracker@7.0.2': resolution: {integrity: sha512-TfeFU9TgN1N06hyb/pV/63FfO34nijZRMqgHk0TJ3gkl4Fbd+wZ73+ZtOd7jag6hMmzO9HSrBc6Vdn591nhkAg==} + '@mui/icons-material@7.1.0': + resolution: {integrity: sha512-1mUPMAZ+Qk3jfgL5ftRR06ATH/Esi0izHl1z56H+df6cwIlCWG66RXciUqeJCttbOXOQ5y2DCjLZI/4t3Yg3LA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@mui/material': ^7.1.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@mui/material@7.0.2': resolution: {integrity: sha512-rjJlJ13+3LdLfobRplkXbjIFEIkn6LgpetgU/Cs3Xd8qINCCQK9qXQIjjQ6P0FXFTPFzEVMj0VgBR1mN+FhOcA==} engines: {node: '>=14.0.0'} @@ -732,6 +756,11 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@suspensive/react@2.18.12': + resolution: {integrity: sha512-De3sVLxLnMpTSOfW3t3D8uh8+/bK8+L/mV8YRAwjW2PyR8BBe9+nctFRVO+ZCIFKUs7VPtnIXnb+5bKfBQ1vog==} + peerDependencies: + react: ^18 || ^19 + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} engines: {node: '>=14'} @@ -799,10 +828,6 @@ packages: engines: {node: '>=14'} peerDependencies: '@svgr/core': '*' - '@suspensive/react@2.18.12': - resolution: {integrity: sha512-De3sVLxLnMpTSOfW3t3D8uh8+/bK8+L/mV8YRAwjW2PyR8BBe9+nctFRVO+ZCIFKUs7VPtnIXnb+5bKfBQ1vog==} - peerDependencies: - react: ^18 || ^19 '@tanstack/query-core@5.72.2': resolution: {integrity: sha512-fxl9/0yk3mD/FwTmVEf1/H6N5B975H0luT+icKyX566w6uJG0x6o+Yl+I38wJRCaogiMkstByt+seXfDbWDAcA==} @@ -2643,6 +2668,12 @@ packages: '@mdx-js/mdx': <2 vite: <3 + vite-plugin-mkcert@1.17.8: + resolution: {integrity: sha512-S+4tNEyGqdZQ3RLAG54ETeO2qyURHWrVjUWKYikLAbmhh/iJ+36gDEja4OWwFyXNuvyXcZwNt5TZZR9itPeG5Q==} + engines: {node: '>=v16.7.0'} + peerDependencies: + vite: '>=3' + vite-plugin-svgr@4.3.0: resolution: {integrity: sha512-Jy9qLB2/PyWklpYy0xk0UU3TlU0t2UMpJXZvf+hWII1lAmRHrOUKi11Uw8N3rxoNk7atZNYO3pR3vI1f7oi+6w==} peerDependencies: @@ -2827,6 +2858,8 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 + '@babel/runtime@7.27.1': {} + '@babel/template@7.27.0': dependencies: '@babel/code-frame': 7.26.2 @@ -3135,6 +3168,14 @@ snapshots: '@mui/core-downloads-tracker@7.0.2': {} + '@mui/icons-material@7.1.0(@mui/material@7.0.2(@emotion/react@11.14.0(@types/react@19.1.1)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.1)(react@19.1.0))(@types/react@19.1.1)(react@19.1.0))(@types/react@19.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.1)(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.1 + '@mui/material': 7.0.2(@emotion/react@11.14.0(@types/react@19.1.1)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.1)(react@19.1.0))(@types/react@19.1.1)(react@19.1.0))(@types/react@19.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.1 + '@mui/material@7.0.2(@emotion/react@11.14.0(@types/react@19.1.1)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.1)(react@19.1.0))(@types/react@19.1.1)(react@19.1.0))(@types/react@19.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.0 @@ -3298,6 +3339,10 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@suspensive/react@2.18.12(react@19.1.0)': + dependencies: + react: 19.1.0 + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 @@ -3367,9 +3412,6 @@ snapshots: svg-parser: 2.0.4 transitivePeerDependencies: - supports-color - '@suspensive/react@2.18.12(react@19.1.0)': - dependencies: - react: 19.1.0 '@tanstack/query-core@5.72.2': {} @@ -3634,9 +3676,9 @@ snapshots: axe-core@4.10.3: {} - axios@1.8.4: + axios@1.8.4(debug@4.4.0): dependencies: - follow-redirects: 1.15.9 + follow-redirects: 1.15.9(debug@4.4.0) form-data: 4.0.2 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -4309,7 +4351,9 @@ snapshots: flatted@3.3.3: {} - follow-redirects@1.15.9: {} + follow-redirects@1.15.9(debug@4.4.0): + optionalDependencies: + debug: 4.4.0 for-each@0.3.5: dependencies: @@ -5718,6 +5762,15 @@ snapshots: unified: 9.2.2 vite: 6.2.6(@types/node@22.15.3) + vite-plugin-mkcert@1.17.8(vite@6.2.6(@types/node@22.15.3)): + dependencies: + axios: 1.8.4(debug@4.4.0) + debug: 4.4.0 + picocolors: 1.1.1 + vite: 6.2.6(@types/node@22.15.3) + transitivePeerDependencies: + - supports-color + vite-plugin-svgr@4.3.0(rollup@4.39.0)(typescript@5.7.3)(vite@6.2.6(@types/node@22.15.3)): dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.39.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..efc037a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - esbuild diff --git a/public/favicon-180.png b/public/favicon-180.png new file mode 100755 index 0000000..232c395 Binary files /dev/null and b/public/favicon-180.png differ diff --git a/public/favicon-192.png b/public/favicon-192.png new file mode 100755 index 0000000..a48d5be Binary files /dev/null and b/public/favicon-192.png differ diff --git a/public/favicon-512.png b/public/favicon-512.png new file mode 100755 index 0000000..785bbdc Binary files /dev/null and b/public/favicon-512.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100755 index 0000000..9b77659 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100755 index 0000000..53e26f3 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100755 index 0000000..13d3b71 --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,25 @@ +{ + "name": "PyCon Korea 2025", + "icons": [ + { + "src": "favicon-192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "favicon-512.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + }, + { + "src": "favicon-512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "id": "/", + "start_url": "/", + "scope": "/", + "display": "standalone" +} diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App.css b/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/src/App.tsx b/src/App.tsx index a4ae52e..39a4523 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,10 @@ -import { BrowserRouter, Routes, Route } from "react-router-dom"; +import React from "react"; + +import { BrowserRouter, Route, Routes } from "react-router-dom"; import MainLayout from "./components/layout"; -import Test from "./components/Test"; +import { Test } from "./components/pages/test"; -function App() { +export const App: React.FC = () => { return ( @@ -13,5 +15,3 @@ function App() { ); } - -export default App; diff --git a/src/components/pages/test.tsx b/src/components/pages/test.tsx new file mode 100644 index 0000000..bf3c553 --- /dev/null +++ b/src/components/pages/test.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { Box, Button } from '@mui/material'; +import { MdiTestPage } from "@src/debug/page/mdi_test"; +import { ShopTestPage } from '@src/debug/page/shop_test'; + +type SelectedTabType = "shop" | "mdi"; + +export const Test: React.FC = () => { + const [selectedTab, setSelectedTab] = React.useState("shop"); + + return + + + {selectedTab === "shop" && } + {selectedTab === "mdi" && } + +} diff --git a/src/components/Test.tsx b/src/debug/page/mdi_test.tsx similarity index 96% rename from src/components/Test.tsx rename to src/debug/page/mdi_test.tsx index 9a5b6ae..8bf2ffc 100644 --- a/src/components/Test.tsx +++ b/src/debug/page/mdi_test.tsx @@ -1,17 +1,18 @@ -import { useState } from "react"; -import { MDXProvider } from "@mdx-js/react"; -import * as runtime from "react/jsx-runtime"; + +import styled from "@emotion/styled"; import { evaluate } from "@mdx-js/mdx"; +import { MDXProvider } from "@mdx-js/react"; import { Box, Button, - TextField, - Typography, Card, CardContent, Stack, + TextField, + Typography, } from "@mui/material"; -import styled from "@emotion/styled"; +import React, { useState } from "react"; +import * as runtime from "react/jsx-runtime"; // styled 컴포넌트 방식 const StyledCard = styled(Card)` @@ -33,7 +34,7 @@ const StyledCardContent = styled(CardContent)` } `; -export default function Test() { +export const MdiTestPage: React.FC = () => { const [mdxInput, setMdxInput] = useState(""); const [Content, setContent] = useState(() => () => null); const [count, setCount] = useState(0); diff --git a/src/debug/page/shop_component/cart.tsx b/src/debug/page/shop_component/cart.tsx new file mode 100644 index 0000000..76a903c --- /dev/null +++ b/src/debug/page/shop_component/cart.tsx @@ -0,0 +1,102 @@ +import React from "react"; + +import { ExpandMore } from '@mui/icons-material'; +import { Accordion, AccordionActions, AccordionDetails, AccordionSummary, Button, CircularProgress, Divider, Stack, Typography } from "@mui/material"; +import { wrap } from "@suspensive/react"; +import { useQueryClient } from '@tanstack/react-query'; + +import ShopComponent from "@pyconkr-shop/components"; +import ShopAPIHook from "@pyconkr-shop/hooks"; +import ShopAPISchema from '@pyconkr-shop/schemas'; +import ShopAPIUtil from "@pyconkr-shop/utils"; + +const ShopCartItem: React.FC<{ + cartProdRel: ShopAPISchema.OrderProductItem, + removeItemFromCartFunc: (cartProductId: string) => void, + disabled?: boolean +}> = ({ cartProdRel, disabled, removeItemFromCartFunc }) => + }> + + {cartProdRel.product.name} + + + + + { + cartProdRel.options.map( + (optionRel) => + ) + } + +
+ +
+ + 상품 가격: + +
+ + + +
+ + +export const ShopCartList: React.FC<{ onPaymentCompleted?: () => void; }> = ({ onPaymentCompleted }) => { + const queryClient = useQueryClient(); + const cartOrderStartMutation = ShopAPIHook.usePrepareCartOrderMutation(); + const removeItemFromCartMutation = ShopAPIHook.useRemoveItemFromCartMutation(); + + const removeItemFromCart = (cartProductId: string) => removeItemFromCartMutation.mutate({ cartProductId }); + const startCartOrder = () => cartOrderStartMutation.mutate( + undefined, + { + onSuccess: (order: ShopAPISchema.Order) => { + ShopAPIUtil.startPortOnePurchase( + order, + () => { + queryClient.invalidateQueries(); + queryClient.resetQueries(); + onPaymentCompleted?.(); + }, + (response) => alert("결제를 실패했습니다!\n" + response.error_msg), + () => { } + ); + }, + onError: (error) => alert(error.message || '결제 준비 중 문제가 발생했습니다,\n잠시 후 다시 시도해주세요.'), + } + ); + + const disabled = removeItemFromCartMutation.isPending || cartOrderStartMutation.isPending; + + const WrappedShopCartList = wrap + .ErrorBoundary({ fallback:
장바구니 정보를 불러오는 중 문제가 발생했습니다.
}) + .Suspense({ fallback: }) + .on(() => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { data } = ShopAPIHook.useCart(); + + return data.products.length === 0 + ? 장바구니가 비어있어요! + : <> + {data.products.map((prodRel) => )} +
+ + + 결제 금액: + + + + }); + + return <> + Cart List + + + + +} diff --git a/src/debug/page/shop_component/order.tsx b/src/debug/page/shop_component/order.tsx new file mode 100644 index 0000000..ddb77b8 --- /dev/null +++ b/src/debug/page/shop_component/order.tsx @@ -0,0 +1,137 @@ +import React from "react"; +import * as R from "remeda"; + +import { ExpandMore } from '@mui/icons-material'; +import { + Accordion, + AccordionActions, + AccordionDetails, + AccordionSummary, + Button, + CircularProgress, + Divider, + List, + Stack, + Typography +} from "@mui/material"; +import { wrap } from "@suspensive/react"; + +import { getFormValue, isFormValid } from '@pyconkr-common/utils/form'; +import ShopComponent from "@pyconkr-shop/components"; +import ShopAPIHook from "@pyconkr-shop/hooks"; +import ShopAPISchema from "@pyconkr-shop/schemas"; +import ShopAPIUtil from '@pyconkr-shop/utils'; + +const PaymentHistoryStatusTranslated: { [k in ShopAPISchema.PaymentHistoryStatus]: string } = { + "pending": "결제 대기중", + "completed": "결제 완료", + "partial_refunded": "부분 환불됨", + "refunded": "환불됨", +} + +const ShopOrderItem: React.FC<{ order: ShopAPISchema.Order, disabled?: boolean }> = ({ order, disabled }) => { + const orderRefundMutation = ShopAPIHook.useOrderRefundMutation(); + const oneItemRefundMutation = ShopAPIHook.useOneItemRefundMutation(); + const optionsOfOneItemInOrderPatchMutation = ShopAPIHook.useOptionsOfOneItemInOrderPatchMutation(); + + const receiptUrl = `${import.meta.env.VITE_PYCONKR_SHOP_API_DOMAIN}/v1/orders/${order.id}/receipt/` + + const refundOrder = () => orderRefundMutation.mutate({ order_id: order.id }); + const openReceipt = () => window.open(receiptUrl, '_blank'); + + const isPending = disabled || orderRefundMutation.isPending || oneItemRefundMutation.isPending || optionsOfOneItemInOrderPatchMutation.isPending; + const btnDisabled = isPending || !R.isNullish(order.not_fully_refundable_reason); + const btnText = R.isNullish(order.not_fully_refundable_reason) ? '주문 전체 환불' : order.current_status === 'refunded' ? '주문 전체 환불됨' : order.not_fully_refundable_reason; + + return + }> + {order.name} + + + +
+ 주문 결제 금액 : + 상태: {PaymentHistoryStatusTranslated[order.current_status]} +
+ +
+ 주문 상품 목록 +
+ { + order.products.map( + (prodRels) => { + const formRef = React.useRef(null); + const currentCustomOptionValues: { [k: string]: string } = prodRels.options + .filter((optionRel) => ShopAPIUtil.isOrderProductOptionModifiable(optionRel)) + .reduce((acc, optionRel) => ({ ...acc, [optionRel.product_option_group.id]: optionRel.custom_response }), {}); + + const hasPatchableOption = Object.entries(currentCustomOptionValues).length > 0; + const patchOptionBtnDisabled = isPending || !hasPatchableOption; + + const refundBtnDisabled = isPending || !R.isNullish(prodRels.not_refundable_reason); + const refundBtnText = R.isNullish(prodRels.not_refundable_reason) ? '단일 상품 환불' : prodRels.status === 'refunded' ? '환불됨' : prodRels.not_refundable_reason; + + const refundOneItem = () => oneItemRefundMutation.mutate({ order_id: order.id, order_product_relation_id: prodRels.id }); + const patchOneItemOptions = () => { + if (!isFormValid(formRef.current)) + throw new Error('Form is not valid'); + + const modifiedCustomOptionValues: ShopAPISchema.OrderOptionsPatchRequest['options'] = Object.entries(getFormValue<{ [key: string]: string }>({ form: formRef.current })) + .filter(([key, value]) => currentCustomOptionValues[key] !== value) + .map(([key, value]) => ({ order_product_option_relation: key, custom_response: value })); + + optionsOfOneItemInOrderPatchMutation.mutate({ order_id: order.id, order_product_relation_id: prodRels.id, options: modifiedCustomOptionValues }); + } + + return + }>{prodRels.product.name} + +
{ e.preventDefault(); patchOneItemOptions(); }}> + + { + prodRels.options.map( + (optionRel) => + ) + } + +
+
+ + + + +
+ } + ) + } +
+ +
+ + + + +
+} + +export const ShopOrderList: React.FC = () => { + const WrappedShopOrderList = wrap + .ErrorBoundary({ fallback:
주문 내역을 불러오는 중 문제가 발생했습니다.
}) + .Suspense({ fallback: }) + .on(() => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { data } = ShopAPIHook.useOrders(); + return {data.map((item) => )} + }) + + return <> + Order List + + + + +} diff --git a/src/debug/page/shop_component/product.tsx b/src/debug/page/shop_component/product.tsx new file mode 100644 index 0000000..cf29de8 --- /dev/null +++ b/src/debug/page/shop_component/product.tsx @@ -0,0 +1,156 @@ +import React from "react"; +import * as R from "remeda"; + +import { ExpandMore } from "@mui/icons-material"; +import { + Accordion, + AccordionActions, + AccordionDetails, + AccordionSummary, + Button, + ButtonProps, + CircularProgress, + Divider, + List, + Stack, + Typography +} from "@mui/material"; +import { wrap } from "@suspensive/react"; +import { useQueryClient } from '@tanstack/react-query'; + +import { MDXRenderer } from "@pyconkr-common/components/mdx"; +import { getFormValue, isFormValid } from '@pyconkr-common/utils/form'; +import ShopComponent from '@pyconkr-shop/components'; +import ShopAPIHook from "@pyconkr-shop/hooks"; +import ShopAPISchema from "@pyconkr-shop/schemas"; +import ShopAPIUtil from "@pyconkr-shop/utils"; + +const getCartAppendRequestPayload = (product: ShopAPISchema.Product, formRef: React.RefObject): ShopAPISchema.CartItemAppendRequest => { + if (!isFormValid(formRef.current)) + throw new Error('Form is not valid'); + + const options = Object.entries(getFormValue<{ [key: string]: string }>({ form: formRef.current })).map(([product_option_group, value]) => { + const optionGroup = product.option_groups.find((group) => group.id === product_option_group); + if (!optionGroup) throw new Error(`Option group ${product_option_group} not found`); + + const product_option = optionGroup.is_custom_response ? null : value; + const custom_response = optionGroup.is_custom_response ? value : null; + return { product_option_group, product_option, custom_response } + }); + return { product: product.id, options }; +} + +const getProductNotPurchasableReason = (product: ShopAPISchema.Product): string | null => { + // 상품이 구매 가능 기간 내에 있고, 상품이 매진되지 않았으며, 매진된 상품 옵션 재고가 없으면 true + const now = new Date(); + const orderableStartsAt = new Date(product.orderable_starts_at); + const orderableEndsAt = new Date(product.orderable_ends_at); + if (orderableStartsAt > now) return `아직 구매할 수 없어요!\n(${orderableStartsAt.toLocaleString()}부터 구매하실 수 있어요.)`; + if (orderableEndsAt < now) return '판매가 종료됐어요!'; + + if (R.isNumber(product.leftover_stock) && product.leftover_stock <= 0) return '상품이 품절되었어요!'; + if (product.option_groups.some((og) => !R.isEmpty(og.options) && og.options.every((o) => R.isNumber(o.leftover_stock) && o.leftover_stock <= 0))) + return '선택 가능한 상품 옵션이 모두 품절되어 구매할 수 없어요!'; + + return null; +} + +const NotPurchasable: React.FC = ({ children }) => { + return + {children} + +} + +const ShopProductItem: React.FC<{ product: ShopAPISchema.Product; onPaymentCompleted?: () => void; }> = ({ product, onPaymentCompleted }) => { + const optionFormRef = React.useRef(null); + + const queryClient = useQueryClient(); + const oneItemOrderStartMutation = ShopAPIHook.usePrepareOneItemOrderMutation(); + const addItemToCartMutation = ShopAPIHook.useAddItemToCartMutation(); + + const addItemToCart = () => addItemToCartMutation.mutate(getCartAppendRequestPayload(product, optionFormRef)); + const oneItemOrderStart = () => oneItemOrderStartMutation.mutate( + getCartAppendRequestPayload(product, optionFormRef), + { + onSuccess: (order: ShopAPISchema.Order) => { + ShopAPIUtil.startPortOnePurchase( + order, + () => { + queryClient.invalidateQueries(); + queryClient.resetQueries(); + onPaymentCompleted?.(); + }, + (response) => alert("결제를 실패했습니다!\n" + response.error_msg), + () => { } + ); + }, + onError: (error) => alert(error.message || '결제 준비 중 문제가 발생했습니다,\n잠시 후 다시 시도해주세요.'), + } + ); + + const formOnSubmit: React.FormEventHandler = (e) => { + e.preventDefault(); + e.stopPropagation(); + } + const disabled = oneItemOrderStartMutation.isPending || addItemToCartMutation.isPending; + + const notPurchasableReason = getProductNotPurchasableReason(product); + const actionButtonProps: ButtonProps = { + variant: "contained", + color: "secondary", + disabled, + } + + return + } sx={{ m: '0' }}> + + {product.name} + + + + +
+ + 로그인 후 장바구니에 담거나 구매할 수 있어요.}> + { + R.isNullish(notPurchasableReason) + ? <> +
+
+ + {product.option_groups.map((group) => )} + +
+
+ +
+ 결제 금액: + + : {notPurchasableReason} + } +
+
+ { + R.isNullish(notPurchasableReason) && + + + + } +
+} + +export const ShopProductList: React.FC = () => { + const WrappedShopProductList = wrap + .ErrorBoundary({ fallback:
상품 목록을 불러오는 중 문제가 발생했습니다.
}) + .Suspense({ fallback: }) + .on(() => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { data } = ShopAPIHook.useProducts(); + return {data.map((product) => )} + }) + + return + Product List + + +} diff --git a/src/debug/page/shop_component/user_status.tsx b/src/debug/page/shop_component/user_status.tsx new file mode 100644 index 0000000..759864e --- /dev/null +++ b/src/debug/page/shop_component/user_status.tsx @@ -0,0 +1,58 @@ +import React from "react"; + +import { Button, CircularProgress, Stack, TextField, Typography } from "@mui/material"; +import { wrap } from "@suspensive/react"; + +import { getFormValue, isFormValid } from "@pyconkr-common/utils/form"; +import ShopAPIHook from "@pyconkr-shop/hooks"; + +export const ShopUserStatus: React.FC = () => { + const formRef = React.useRef(null); + const signInWithEmailMutation = ShopAPIHook.useSignInWithEmailMutation(); + const SignInWithSNSMutation = ShopAPIHook.useSignInWithSNSMutation(); + const signOutMutation = ShopAPIHook.useSignOutMutation(); + + const signInWithGoogle = () => SignInWithSNSMutation.mutate({ provider: 'google', callback_url: window.location.href }); + const signInWithEmail = (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!isFormValid(formRef.current)) return; + signInWithEmailMutation.mutate(getFormValue<{ email: string; password: string }>({ form: formRef.current })); + } + + const disabled = SignInWithSNSMutation.isPending || signInWithEmailMutation.isPending || signOutMutation.isPending; + + const WrappedShopUserStatus = wrap + .ErrorBoundary({ fallback:
로그인 정보를 불러오는 중 문제가 발생했습니다.
}) + .Suspense({ fallback: }) + .on(() => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { data } = ShopAPIHook.useUserStatus(); + + return (data && data.meta.is_authenticated === true) ? ( + + User: {data.data.user.username} + + + ) : ( + +
+ + + + + +
+ ) + }) + + return + User Status + + +}; diff --git a/src/debug/page/shop_test.tsx b/src/debug/page/shop_test.tsx new file mode 100644 index 0000000..efe38f2 --- /dev/null +++ b/src/debug/page/shop_test.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +import { Divider, Stack, Typography } from "@mui/material"; + +import { ShopCartList } from '@src/debug/page/shop_component/cart'; +import { ShopOrderList } from "@src/debug/page/shop_component/order"; +import { ShopProductList } from "@src/debug/page/shop_component/product"; +import { ShopUserStatus } from "@src/debug/page/shop_component/user_status"; + +export const ShopTestPage: React.FC = () => + + Shop Test Page + + + + + + + + + diff --git a/src/index.css b/src/index.css deleted file mode 100644 index e69de29..0000000 diff --git a/src/main.tsx b/src/main.tsx index edcb36c..e229113 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,11 +1,23 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ThemeProvider, CssBaseline } from "@mui/material"; + import { Global } from "@emotion/react"; -import { muiTheme, globalStyles } from "./styles/globalStyles"; -import "./index.css"; -import App from "./App.tsx"; +import { CircularProgress, CssBaseline, ThemeProvider } from "@mui/material"; +import { wrap } from "@suspensive/react"; +import { matchQuery, MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { SnackbarProvider } from "notistack"; + +import { App } from "./App.tsx"; +import { globalStyles, muiTheme } from "./styles/globalStyles"; + +declare module '@tanstack/react-query' { + interface Register { + mutationMeta: { + invalidates?: string[][]; + } + } +} const queryClient = new QueryClient({ defaultOptions: { @@ -15,15 +27,42 @@ const queryClient = new QueryClient({ retry: 1, }, }, + mutationCache: new MutationCache({ + onSuccess: (_data, _variables, _context, mutation) => { + queryClient.invalidateQueries({ predicate: (query) => mutation.meta?.invalidates?.some((queryKey) => matchQuery({ queryKey }, query)) ?? true }) + }, + }), }); +const CenteredPage: React.FC = ({ children }) => ( +
+ +
+); + +const ErrorBoundariedApp: React.FC = wrap + .ErrorBoundary({ fallback: 문제가 발생했습니다, 새로고침을 해주세요. }) + .Suspense({ fallback: }) + .on(() => ); + createRoot(document.getElementById("root")!).render( + - - - + + + + + diff --git a/vite.config.ts b/vite.config.ts index 1fe7815..303a9e2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,13 +2,14 @@ import mdx from "@mdx-js/rollup"; import react from "@vitejs/plugin-react"; import path from "path"; import { defineConfig } from "vite"; +import mkcert from "vite-plugin-mkcert"; import svgr from "vite-plugin-svgr"; // https://vitejs.dev/config/ export default defineConfig({ - base: "/frontend/", + base: "/", envDir: "./dotenv", - plugins: [react(), mdx(), svgr()], + plugins: [react(), mdx(), mkcert({ hosts: ["local.dev.pycon.kr"] }), svgr()], resolve: { alias: { "@pyconkr-common": path.resolve(__dirname, "./package/pyconkr-common"),