Skip to content

Commit 17d9cdf

Browse files
authored
Merge pull request #3 from pythonkr/feature/add-shop-api-component
feat: pyconkr-shop Component 및 테스트 페이지 추가
2 parents 637a72b + f278ee7 commit 17d9cdf

33 files changed

+946
-109
lines changed

dotenv/.env.development

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

dotenv/.env.production

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

index.html

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,46 @@
11
<!doctype html>
22
<html lang="en">
33
<head>
4-
<meta charset="UTF-8" />
5-
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
6-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<title>Vite + React + TS</title>
4+
<meta charSet="UTF-8" />
5+
<base href="/" />
6+
<link rel="icon" href="/favicon.ico" sizes="32x32">
7+
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
8+
<link rel="apple-touch-icon" href="/favicon-180.png">
9+
10+
<meta name="theme-color" content="#fff" />
11+
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff" />
12+
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#fff" />
13+
14+
<meta name="msapplication-navbutton-color" content="#fff" />
15+
<meta name="msapplication-TileColor" content="#fff" />
16+
<meta name="msapplication-TileImage" content="/favicon-192.png" />
17+
<meta name="application-name" content="PyCon KR" />
18+
<meta name="apple-mobile-web-app-title" content="PyCon KR" />
19+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
20+
<meta name="apple-mobile-web-app-capable" content="yes" />
21+
<meta name="mobile-web-app-capable" content="yes" />
22+
<!-- https://developers.google.com/web/fundamentals/web-app-manifest/ -->
23+
<link rel="manifest" href="/site.webmanifest" />
24+
25+
<meta name="viewport" content="width=device-width,
26+
height=device-height,
27+
target-densitydpi=device-dpi,
28+
initial-scale=1.0,
29+
minimum-scale=1.0,
30+
maximum-scale=1.0,
31+
user-scalable=0,
32+
user-scalable=no,
33+
shrink-to-fit=no" />
34+
<meta name="author" content="PyCon Korea Organizing Team" />
35+
<meta name="description" content="Teaser site for PyCon Korea 2025" />
36+
<meta name="keywords" content="PyCon, Python, Conference, Korea, 2025" />
37+
<meta name="google" content="notranslate" />
38+
<meta name="googlebot" content="index, follow" />
39+
<meta name="robots" content="index, follow" />
40+
41+
<script src="https://cdn.iamport.kr/v1/iamport.js"></script>
42+
43+
<title>PyCon Korea 2025</title>
844
</head>
945
<body>
1046
<div id="root"></div>

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@mdx-js/mdx": "^3.1.0",
2020
"@mdx-js/react": "^3.1.0",
2121
"@mdx-js/rollup": "^3.1.0",
22+
"@mui/icons-material": "^7.1.0",
2223
"@mui/material": "^7.0.2",
2324
"@pyconkr-common": "link:package/pyconkr-common",
2425
"@pyconkr-shop": "link:package/pyconkr-shop",
@@ -28,10 +29,12 @@
2829
"axios": "^1.8.4",
2930
"eslint-plugin-import": "^2.31.0",
3031
"eslint-plugin-jsx-a11y": "^6.10.2",
32+
"globals": "^15.15.0",
3133
"notistack": "^3.0.2",
3234
"react": "^19.0.0",
3335
"react-dom": "^19.0.0",
34-
"react-router-dom": "^7.5.3"
36+
"react-router-dom": "^7.5.3",
37+
"remeda": "^2.21.3"
3538
},
3639

3740
"devDependencies": {
@@ -49,14 +52,13 @@
4952
"eslint-plugin-react-hooks": "^5.1.0",
5053
"eslint-plugin-react-refresh": "^0.4.19",
5154
"gh-pages": "^6.3.0",
52-
"globals": "^15.15.0",
5355
"iamport-typings": "^1.4.0",
5456
"prettier": "^3.5.3",
55-
"remeda": "^2.21.3",
5657
"typescript": "~5.7.2",
5758
"typescript-eslint": "^8.24.1",
5859
"vite": "^6.2.0",
5960
"vite-plugin-mdx": "^3.6.1",
61+
"vite-plugin-mkcert": "^1.17.8",
6062
"vite-plugin-svgr": "^4.3.0"
6163

6264
},

package/pyconkr-shop/apis/index.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ namespace ShopAPIRoute {
8686
* @returns 현재 장바구니 상태
8787
*/
8888
export const retrieveCart = () =>
89-
shopAPIClient.get<ShopAPISchema.OrderProductItem[]>("v1/orders/cart/");
89+
shopAPIClient.get<ShopAPISchema.Order>("v1/orders/cart/");
9090

9191
/**
9292
* 장바구니에 상품을 추가합니다.
@@ -138,7 +138,7 @@ namespace ShopAPIRoute {
138138
*/
139139
export const prepareCartOrder = () =>
140140
shopAPIClient.post<ShopAPISchema.Order, undefined>(
141-
"v1/orders/cart/",
141+
"v1/orders/",
142142
undefined
143143
);
144144

@@ -168,6 +168,20 @@ namespace ShopAPIRoute {
168168
*/
169169
export const refundAllItemsInOrder = (data: { order_id: string }) =>
170170
shopAPIClient.delete<void>(`v1/orders/${data.order_id}/`);
171+
172+
/**
173+
* 결제 완료된 주문의 사용자 정의 응답용 상품 옵션을 수정합니다.
174+
* @param data.order_id - 수정할 주문 내역의 UUID
175+
* @param data.order_product_relation_id - 수정할 주문 내역 내 상품의 UUID
176+
* @param data.options - 수정할 상품 옵션 정보
177+
* @param data.options[].order_product_option_relation - 수정할 상품 옵션의 UUID
178+
* @param data.options[].custom_response - 수정할 상품 옵션에 대한 사용자 정의 응답
179+
*/
180+
export const patchOrderOptions = async (data: ShopAPISchema.OrderOptionsPatchRequest) =>
181+
shopAPIClient.patch<ShopAPISchema.Order, ShopAPISchema.OrderOptionsPatchRequest['options']>(
182+
`v1/orders/${data.order_id}/products/${data.order_product_relation_id}/options/`,
183+
data.options
184+
);
171185
}
172186

173187
export default ShopAPIRoute;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {
2+
OptionGroupInput as OptionGroupInputComponent,
3+
OrderProductRelationOptionInput as OrderProductRelationOptionInputComponent,
4+
} from "./option_group_input";
5+
import { PriceDisplay as PriceDisplayComponent } from "./price_display";
6+
import { ShopSignInGuard as ShopSignInGuardComponent } from "./signin_guard";
7+
8+
namespace ShopComponent {
9+
export const OptionGroupInput = OptionGroupInputComponent;
10+
export const OrderProductRelationOptionInput = OrderProductRelationOptionInputComponent;
11+
export const PriceDisplay = PriceDisplayComponent;
12+
export const ShopSignInGuard = ShopSignInGuardComponent;
13+
}
14+
15+
export default ShopComponent;
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import React from 'react';
2+
import * as R from 'remeda';
3+
4+
import { FormControl, InputLabel, MenuItem, Select, TextField, Tooltip } from '@mui/material';
5+
6+
import ShopAPISchema from '@pyconkr-shop/schemas';
7+
import ShopAPIUtil from '@pyconkr-shop/utils';
8+
import { PriceDisplay } from './price_display';
9+
10+
type CommonOptionGroupType = {
11+
id: string;
12+
name: string;
13+
}
14+
type SelectableOptionGroupType = CommonOptionGroupType & {
15+
is_custom_response: false;
16+
custom_response_pattern: null;
17+
}
18+
type CustomResponseOptionGroupType = CommonOptionGroupType & {
19+
is_custom_response: true;
20+
custom_response_pattern: string;
21+
}
22+
type OptionGroupType = SelectableOptionGroupType | CustomResponseOptionGroupType;
23+
24+
type SimplifiedOption = Pick<ShopAPISchema.Option, ('id' | 'name' | 'additional_price' | 'leftover_stock')>;
25+
26+
const isFilledString = (str: unknown): str is string => R.isString(str) && !R.isEmpty(str);
27+
28+
const SelectableOptionGroupInput: React.FC<{
29+
optionGroup: SelectableOptionGroupType;
30+
options: SimplifiedOption[];
31+
defaultValue?: string;
32+
disabled?: boolean;
33+
disabledReason?: string;
34+
}> = ({ optionGroup, options, defaultValue, disabled, disabledReason }) => {
35+
const optionElements = options.map(
36+
(option) => {
37+
const isOptionOutOfStock = R.isNumber(option.leftover_stock) && option.leftover_stock <= 0;
38+
39+
return <MenuItem key={option.id} value={option.id} disabled={disabled || isOptionOutOfStock}>
40+
{option.name}
41+
{option.additional_price > 0 && (<> [ +<PriceDisplay price={option.additional_price} /> ]</>)}
42+
{isOptionOutOfStock && <> (품절)</>}
43+
</MenuItem>
44+
}
45+
)
46+
47+
const selectElement = <FormControl fullWidth>
48+
<InputLabel id={`${optionGroup.id}label`}>{optionGroup.name}</InputLabel>
49+
<Select label={`${optionGroup.id}label`} name={optionGroup.id} defaultValue={defaultValue} disabled={disabled} required>
50+
{optionElements}
51+
</Select>
52+
</FormControl>
53+
54+
return isFilledString(disabledReason) ? <Tooltip title={disabledReason}>{selectElement}</Tooltip> : selectElement
55+
}
56+
57+
const CustomResponseOptionGroupInput: React.FC<{
58+
optionGroup: CustomResponseOptionGroupType;
59+
defaultValue?: string;
60+
disabled?: boolean;
61+
disabledReason?: string;
62+
}> = ({ optionGroup, defaultValue, disabled, disabledReason }) => {
63+
const pattern = ShopAPIUtil.getCustomResponsePattern(optionGroup)?.source;
64+
65+
const textFieldElement = <TextField
66+
label={optionGroup.name}
67+
name={optionGroup.id}
68+
required
69+
defaultValue={defaultValue}
70+
disabled={disabled}
71+
slotProps={{ htmlInput: { pattern } }} />
72+
73+
return isFilledString(disabledReason) ? <Tooltip title={disabledReason}>{textFieldElement}</Tooltip> : textFieldElement
74+
}
75+
76+
export const OptionGroupInput: React.FC<{
77+
optionGroup: OptionGroupType;
78+
options: SimplifiedOption[];
79+
80+
defaultValue?: string;
81+
disabled?: boolean;
82+
disabledReason?: string;
83+
}> = ({ optionGroup, options, defaultValue, disabled, disabledReason }) => optionGroup.is_custom_response
84+
? <CustomResponseOptionGroupInput optionGroup={optionGroup} defaultValue={defaultValue} disabled={disabled} disabledReason={disabledReason} />
85+
: <SelectableOptionGroupInput optionGroup={optionGroup} options={options} defaultValue={defaultValue} disabled={disabled} disabledReason={disabledReason} />
86+
87+
88+
export const OrderProductRelationOptionInput: React.FC<{
89+
optionRel: ShopAPISchema.OrderProductItem["options"][number];
90+
disabled?: boolean;
91+
disabledReason?: string;
92+
}> = ({ optionRel, disabled, disabledReason }) => {
93+
let defaultValue: string | null = null;
94+
let guessedDisabledReason: string | undefined = undefined;
95+
let dummyOptions: { id: string; name: string; additional_price: number; leftover_stock: number | null }[] = [];
96+
97+
// type hinting을 위해 if문을 사용함
98+
if (optionRel.product_option_group.is_custom_response === false && R.isNonNull(optionRel.product_option)) {
99+
defaultValue = optionRel.product_option.id;
100+
guessedDisabledReason = '추가 비용이 발생하는 옵션은 수정할 수 없어요.';
101+
dummyOptions = [
102+
{
103+
id: optionRel.product_option.id,
104+
name: optionRel.product_option.name,
105+
additional_price: optionRel.product_option.additional_price || 0,
106+
leftover_stock: null,
107+
}
108+
]
109+
} else {
110+
defaultValue = optionRel.custom_response;
111+
}
112+
113+
return <OptionGroupInput
114+
key={optionRel.product_option_group.id}
115+
optionGroup={optionRel.product_option_group}
116+
options={dummyOptions}
117+
defaultValue={defaultValue || undefined}
118+
disabled={disabled || !ShopAPIUtil.isOrderProductOptionModifiable(optionRel)}
119+
disabledReason={disabledReason || guessedDisabledReason}
120+
/>
121+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import React from 'react';
2+
3+
import { Typography } from '@mui/material';
4+
5+
import ShopAPIHook from '@pyconkr-shop/hooks';
6+
7+
export const ShopSignInGuard: React.FC<{ children: React.ReactNode, fallback?: React.ReactNode }> = ({ children, fallback }) => {
8+
const { data } = ShopAPIHook.useUserStatus();
9+
const renderedFallback = fallback || <Typography variant="h6" gutterBottom>로그인 후 이용해주세요.</Typography>;
10+
return (data && data.meta.is_authenticated === true) ? children : renderedFallback;
11+
};

package/pyconkr-shop/hooks/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ namespace ShopAPIHook {
140140
mutationFn: ShopAPIRoute.refundAllItemsInOrder,
141141
meta: { invalidates: [QUERY_KEYS.ORDER_LIST] },
142142
});
143+
144+
export const useOptionsOfOneItemInOrderPatchMutation = () =>
145+
useMutation({
146+
mutationKey: MUTATION_KEYS.CART_ITEM_APPEND,
147+
mutationFn: ShopAPIRoute.patchOrderOptions,
148+
meta: { invalidates: [QUERY_KEYS.ORDER_LIST] },
149+
});
143150
}
144151

145152
export default ShopAPIHook;

0 commit comments

Comments
 (0)