@sayren/mcp 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/index.mjs +6 -4
  2. package/package.json +3 -3
  3. package/template/.template-meta.json +2 -2
  4. package/template/README.md +32 -2
  5. package/template/package.json +6 -0
  6. package/template/src/components/fulfillment-codes.tsx +110 -0
  7. package/template/src/components/fulfillment-downloads.tsx +88 -0
  8. package/template/src/components/text-field.tsx +71 -0
  9. package/template/src/lib/catalog-queries.ts +47 -0
  10. package/template/src/lib/code-reveal.test.ts +30 -0
  11. package/template/src/lib/code-reveal.ts +32 -0
  12. package/template/src/lib/coupon-code.test.ts +19 -0
  13. package/template/src/lib/coupon-code.ts +60 -0
  14. package/template/src/lib/download.test.ts +41 -0
  15. package/template/src/lib/download.ts +41 -0
  16. package/template/src/lib/form-schemas.test.ts +144 -0
  17. package/template/src/lib/form-schemas.ts +131 -0
  18. package/template/src/lib/guest-order.test.ts +18 -0
  19. package/template/src/lib/guest-order.ts +13 -0
  20. package/template/src/lib/order-status.test.ts +29 -0
  21. package/template/src/lib/order-status.ts +28 -2
  22. package/template/src/lib/page-title.test.ts +19 -0
  23. package/template/src/lib/page-title.ts +23 -0
  24. package/template/src/lib/products-search.test.ts +25 -0
  25. package/template/src/lib/products-search.ts +49 -0
  26. package/template/src/router.tsx +26 -1
  27. package/template/src/routes/__root.tsx +13 -6
  28. package/template/src/routes/account.tsx +2 -1
  29. package/template/src/routes/auth.callback.ts +13 -9
  30. package/template/src/routes/cart.tsx +13 -6
  31. package/template/src/routes/checkout.complete.tsx +2 -1
  32. package/template/src/routes/checkout.index.tsx +441 -147
  33. package/template/src/routes/checkout.return.tsx +7 -1
  34. package/template/src/routes/guest-order.tsx +174 -72
  35. package/template/src/routes/login.tsx +2 -1
  36. package/template/src/routes/orders.$orderId.tsx +98 -3
  37. package/template/src/routes/orders.index.tsx +2 -1
  38. package/template/src/routes/products.$productId.tsx +4 -1
  39. package/template/src/routes/products.index.tsx +46 -47
  40. package/template/src/routes/signup.tsx +61 -90
@@ -0,0 +1,41 @@
1
+ import { ApiError } from "@sayren/storefront-sdk";
2
+ import { guestLookupLockedMessage } from "./guest-order";
3
+
4
+ /**
5
+ * 파일 다운로드 상품의 파일 받기 (sayren 이슈 #48).
6
+ *
7
+ * 주문 상세에는 횟수·기한·파일 이름만 온다. 「다운로드」를 누르면 서버 함수가 `myOrders.createDownload`(회원)·
8
+ * `myOrders.createGuestDownload`(비회원)로 수명 300초의 서명 주소를 받아 브라우저가 바로 그 주소로 간다.
9
+ * 주소는 받을 때마다 횟수를 하나 쓰므로 저장하거나 공유하지 않는다.
10
+ */
11
+
12
+ /** 서버 함수 결과 — ApiError는 서버 함수 경계를 넘지 못하므로 문구로 바꿔 돌려준다 */
13
+ export type DownloadResult =
14
+ | { url: string; fileName: string; remaining: number; error: null }
15
+ | { url: null; error: string };
16
+
17
+ /** 다운로드 실패 → 구매자에게 보일 문구 */
18
+ export function downloadErrorMessage(error: unknown): string {
19
+ if (error instanceof ApiError) {
20
+ if (error.code === "DOWNLOAD_LIMIT_REACHED") return "다운로드 횟수를 모두 사용했습니다";
21
+ if (error.code === "DOWNLOAD_EXPIRED") return "다운로드 기간이 지났습니다";
22
+ if (error.status === 404) return "받을 파일이 없습니다. 주문 상태를 확인해 주십시오";
23
+ // 비회원 다운로드는 비회원 주문 조회와 같은 시도 한도를 쓴다
24
+ if (error.status === 429) return guestLookupLockedMessage(error.details?.retryAfterSeconds);
25
+ }
26
+ return "파일을 받지 못했습니다. 잠시 후 다시 시도해 주십시오";
27
+ }
28
+
29
+ /** 파일 크기 — 1024 단위, 소수 한 자리. 모르면 빈 문자열 */
30
+ export function formatFileSize(bytes: number | null | undefined): string {
31
+ if (bytes === null || bytes === undefined || bytes < 0) return "";
32
+ if (bytes < 1024) return `${bytes}B`;
33
+ const units = ["KB", "MB", "GB"];
34
+ let size = bytes / 1024;
35
+ let unit = 0;
36
+ while (size >= 1024 && unit < units.length - 1) {
37
+ size /= 1024;
38
+ unit += 1;
39
+ }
40
+ return `${size.toFixed(1)}${units[unit]}`;
41
+ }
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ checkoutFormSchema,
4
+ guestOrderLookupSchema,
5
+ signupFormSchema,
6
+ WITHDRAWAL_AGREEMENT_MESSAGE,
7
+ } from "./form-schemas";
8
+
9
+ const messageOf = (result: { success: boolean; error?: { issues: { message: string }[] } }) =>
10
+ result.error?.issues[0]?.message;
11
+
12
+ describe("가입 폼", () => {
13
+ const valid = {
14
+ email: " buyer@example.com ",
15
+ password: "password-1",
16
+ name: "구매자",
17
+ phone: "",
18
+ agreements: { terms: true, privacy: true, marketing: false },
19
+ };
20
+
21
+ it("앞뒤 공백을 지우고 휴대폰은 비워 둘 수 있다", () => {
22
+ const parsed = signupFormSchema.parse(valid);
23
+ expect(parsed.email).toBe("buyer@example.com");
24
+ expect(parsed.phone).toBe("");
25
+ });
26
+
27
+ it("비밀번호 조합은 SDK 규칙으로 막는다", () => {
28
+ expect(messageOf(signupFormSchema.safeParse({ ...valid, password: "aaaaaaaaaa" }))).toBe(
29
+ "영문/숫자/특수문자 중 2종 이상을 조합해야 합니다",
30
+ );
31
+ });
32
+
33
+ it("필수 동의가 없으면 안내한다", () => {
34
+ const result = signupFormSchema.safeParse({
35
+ ...valid,
36
+ agreements: { terms: false, privacy: true, marketing: false },
37
+ });
38
+ expect(messageOf(result)).toBe("필수 약관에 동의해 주십시오");
39
+ });
40
+ });
41
+
42
+ describe("비회원 주문 조회 폼", () => {
43
+ it("연락처는 숫자만 남긴다", () => {
44
+ const parsed = guestOrderLookupSchema.parse({
45
+ orderId: " ord_1 ",
46
+ phone: "010-1234-5678",
47
+ orderPassword: "secret",
48
+ });
49
+ expect(parsed).toEqual({ orderId: "ord_1", phone: "01012345678", orderPassword: "secret" });
50
+ });
51
+
52
+ it("연락처 형식이 아니면 안내한다", () => {
53
+ const result = guestOrderLookupSchema.safeParse({
54
+ orderId: "ord_1",
55
+ phone: "1234",
56
+ orderPassword: "secret",
57
+ });
58
+ expect(messageOf(result)).toContain("연락처 형식");
59
+ });
60
+ });
61
+
62
+ describe("주문서 폼", () => {
63
+ const shipping = {
64
+ receiverName: "홍길동",
65
+ phone: "01012345678",
66
+ zipCode: "04524",
67
+ address1: "서울특별시 중구 세종대로 110",
68
+ address2: "",
69
+ deliveryMemo: "",
70
+ paymentOption: "tosspayments:TRANSFER",
71
+ cashReceiptOn: false,
72
+ cashReceiptType: "INCOME_DEDUCTION" as const,
73
+ };
74
+ const cashKeys = new Set(["tosspayments:TRANSFER"]);
75
+
76
+ it("회원은 비회원 주문 정보 없이 통과한다", () => {
77
+ const schema = checkoutFormSchema({ guest: false, cashReceiptOptionKeys: cashKeys });
78
+ expect(schema.safeParse(shipping).success).toBe(true);
79
+ });
80
+
81
+ it("배송이 필요 없는 주문은 배송지 없이 통과하고, 비회원이면 연락처만 받는다", () => {
82
+ const member = checkoutFormSchema({
83
+ guest: false,
84
+ cashReceiptOptionKeys: cashKeys,
85
+ shipping: false,
86
+ });
87
+ expect(
88
+ member.safeParse({
89
+ paymentOption: "tosspayments:CARD",
90
+ cashReceiptOn: false,
91
+ cashReceiptType: "INCOME_DEDUCTION",
92
+ }).success,
93
+ ).toBe(true);
94
+ const guest = checkoutFormSchema({
95
+ guest: true,
96
+ cashReceiptOptionKeys: cashKeys,
97
+ shipping: false,
98
+ });
99
+ const guestInput = {
100
+ paymentOption: "tosspayments:CARD",
101
+ cashReceiptOn: false,
102
+ cashReceiptType: "INCOME_DEDUCTION" as const,
103
+ guestName: "홍길동",
104
+ email: "guest@example.com",
105
+ orderPassword: "123456",
106
+ };
107
+ expect(guest.safeParse(guestInput).success).toBe(false);
108
+ expect(guest.safeParse({ ...guestInput, phone: "01012345678" }).success).toBe(true);
109
+ });
110
+
111
+ it("비회원은 주문 조회 비밀번호 6자 이상이 필요하다", () => {
112
+ const schema = checkoutFormSchema({ guest: true, cashReceiptOptionKeys: cashKeys });
113
+ const result = schema.safeParse({
114
+ ...shipping,
115
+ guestName: "홍길동",
116
+ email: "guest@example.com",
117
+ orderPassword: "12345",
118
+ });
119
+ expect(messageOf(result)).toBe("주문 조회 비밀번호는 6자 이상이어야 합니다");
120
+ });
121
+
122
+ it("청약철회 제한 상품이 있으면 동의를 요구하고, 없으면 묻지 않는다", () => {
123
+ const restricted = checkoutFormSchema({
124
+ guest: false,
125
+ cashReceiptOptionKeys: cashKeys,
126
+ withdrawal: true,
127
+ });
128
+ expect(messageOf(restricted.safeParse({ ...shipping, withdrawalAgreed: false }))).toBe(
129
+ WITHDRAWAL_AGREEMENT_MESSAGE,
130
+ );
131
+ expect(restricted.safeParse({ ...shipping }).success).toBe(false);
132
+ expect(restricted.safeParse({ ...shipping, withdrawalAgreed: true }).success).toBe(true);
133
+ const plain = checkoutFormSchema({ guest: false, cashReceiptOptionKeys: cashKeys });
134
+ expect(plain.safeParse({ ...shipping, withdrawalAgreed: false }).success).toBe(true);
135
+ });
136
+
137
+ it("현금영수증은 받을 수 있는 결제 옵션에서 신청했을 때만 번호를 검사한다", () => {
138
+ const schema = checkoutFormSchema({ guest: false, cashReceiptOptionKeys: cashKeys });
139
+ const on = { ...shipping, cashReceiptOn: true, cashReceiptNumber: "12" };
140
+ expect(schema.safeParse(on).success).toBe(false);
141
+ expect(schema.safeParse({ ...on, paymentOption: "tosspayments:CARD" }).success).toBe(true);
142
+ expect(schema.safeParse({ ...on, cashReceiptNumber: "01012345678" }).success).toBe(true);
143
+ });
144
+ });
@@ -0,0 +1,131 @@
1
+ import {
2
+ cashReceiptRequestSchema,
3
+ cashReceiptTypeSchema,
4
+ guestInfoSchema,
5
+ shippingAddressInputSchema,
6
+ signupRequestSchema,
7
+ } from "@sayren/storefront-sdk";
8
+ import { z } from "zod";
9
+
10
+ /**
11
+ * 폼 스키마 — react-hook-form의 `zodResolver`에 넣는다.
12
+ *
13
+ * 입력 규칙(비밀번호 조합·휴대폰·우편번호·현금영수증 번호)은 SDK 스키마가 원천이다. 여기서는 화면 입력 모양
14
+ * (빈 칸은 `""`, 동의는 체크박스)에 맞추고 안내 문구를 붙이기만 한다. 서버 함수는 보내기 전에 SDK 스키마로
15
+ * 다시 확인하고 서버도 같은 규칙으로 거절하므로, 이 검사는 입력하는 동안의 안내다.
16
+ */
17
+
18
+ /** SDK 규칙을 그대로 쓰되 안내 문구만 바꾼다(SDK 스키마 일부는 문구가 없다) */
19
+ const matches = (schema: z.ZodType, message: string) =>
20
+ z.string().refine((value) => schema.safeParse(value).success, message);
21
+
22
+ // ── 회원가입 ─────────────────────────────────────────────────────────────
23
+
24
+ const signupPhone = signupRequestSchema.shape.phone.unwrap();
25
+
26
+ export const signupFormSchema = z.object({
27
+ email: z.string().trim().pipe(z.email("이메일 형식을 확인해 주십시오")),
28
+ password: signupRequestSchema.shape.password,
29
+ name: z.string().trim().min(1, "이름을 입력해 주십시오").max(50, "이름은 50자까지입니다"),
30
+ /** 선택 입력 — 비우면 보내지 않는다 */
31
+ phone: z
32
+ .string()
33
+ .trim()
34
+ .refine(
35
+ (value) => value === "" || signupPhone.safeParse(value).success,
36
+ "휴대폰 번호 형식을 확인해 주십시오 (예: 01012345678)",
37
+ ),
38
+ // 체크박스는 체크 전 false다 — 입력은 boolean으로 받고 필수 동의만 true를 요구한다
39
+ agreements: z.object({
40
+ terms: z.boolean().refine((agreed) => agreed, "필수 약관에 동의해 주십시오"),
41
+ privacy: z.boolean().refine((agreed) => agreed, "필수 약관에 동의해 주십시오"),
42
+ marketing: z.boolean(),
43
+ }),
44
+ });
45
+
46
+ export type SignupFormInput = z.input<typeof signupFormSchema>;
47
+ export type SignupForm = z.output<typeof signupFormSchema>;
48
+
49
+ // ── 비회원 주문 조회 ──────────────────────────────────────────────────────
50
+
51
+ export const guestOrderLookupSchema = z.object({
52
+ orderId: z.string().trim().min(1, "주문번호를 입력해 주십시오"),
53
+ // 하이픈·공백을 넣어도 숫자만 읽는다
54
+ phone: z
55
+ .string()
56
+ .transform((value) => value.replace(/[^0-9]/g, ""))
57
+ .pipe(matches(guestInfoSchema.shape.phone, "연락처 형식을 확인해 주십시오 (예: 01012345678)")),
58
+ orderPassword: z.string().min(1, "주문 조회 비밀번호를 입력해 주십시오"),
59
+ });
60
+
61
+ export type GuestOrderLookupInput = z.input<typeof guestOrderLookupSchema>;
62
+ export type GuestOrderLookup = z.output<typeof guestOrderLookupSchema>;
63
+
64
+ // ── 주문서 ───────────────────────────────────────────────────────────────
65
+
66
+ /** 청약철회 제한 동의 누락 — 폼 검사와 서버 400 `WITHDRAWAL_AGREEMENT_REQUIRED`가 같은 문구를 쓴다 */
67
+ export const WITHDRAWAL_AGREEMENT_MESSAGE = "청약철회 제한 안내에 동의해 주십시오";
68
+
69
+ /**
70
+ * 주문서 폼. 비회원 주문 정보는 비회원일 때만 받고, 현금영수증은 계좌이체·가상계좌 옵션을 골랐고 신청을 켰을 때만
71
+ * 검사한다(`cashReceiptOptionKeys`는 그런 결제 옵션의 키).
72
+ *
73
+ * 배송지는 주문서의 `requiresShipping`이 true일 때만 받는다(`shipping`, 생략하면 true). 배송이 필요 없는 주문(이용권·방문
74
+ * 수령 등)은 배송지 칸이 없고, 연락처는 비회원 주문자 연락처로만 받는다.
75
+ *
76
+ * 청약철회 제한 동의는 주문서의 `withdrawal.required`가 true일 때만 요구한다(`withdrawal`, 생략하면 false). 결제 시작이
77
+ * `withdrawalAgreement: true`로 보낸다.
78
+ */
79
+ export function checkoutFormSchema(options: {
80
+ guest: boolean;
81
+ cashReceiptOptionKeys: ReadonlySet<string>;
82
+ shipping?: boolean;
83
+ withdrawal?: boolean;
84
+ }) {
85
+ const shipping = shippingAddressInputSchema.shape;
86
+ const address = options.shipping ?? true;
87
+ const optional = z.string().optional();
88
+ return z
89
+ .object({
90
+ receiverName: address ? z.string().trim().pipe(shipping.receiverName) : optional,
91
+ phone:
92
+ address || options.guest ? z.string().trim().pipe(shipping.phone) : z.string().optional(),
93
+ zipCode: address ? z.string().trim().pipe(shipping.zipCode) : optional,
94
+ address1: address ? z.string().trim().pipe(shipping.address1) : optional,
95
+ address2: address ? z.string().trim() : optional,
96
+ deliveryMemo: address ? z.string().trim().max(100, "배송 메모는 100자까지입니다") : optional,
97
+ guestName: options.guest
98
+ ? z.string().trim().min(1, "주문자명을 입력해 주십시오")
99
+ : z.string().optional(),
100
+ email: options.guest
101
+ ? z.string().trim().pipe(z.email("이메일 형식을 확인해 주십시오"))
102
+ : z.string().optional(),
103
+ orderPassword: options.guest ? guestInfoSchema.shape.orderPassword : z.string().optional(),
104
+ paymentOption: z.string().min(1, "결제수단을 골라 주십시오."),
105
+ cashReceiptOn: z.boolean(),
106
+ cashReceiptType: cashReceiptTypeSchema,
107
+ cashReceiptNumber: z.string().optional(),
108
+ // 체크박스는 체크 전 false다 — 제한 상품이 있는 주문만 true를 요구한다
109
+ withdrawalAgreed: options.withdrawal
110
+ ? z.boolean().refine((agreed) => agreed, WITHDRAWAL_AGREEMENT_MESSAGE)
111
+ : z.boolean().optional(),
112
+ })
113
+ .superRefine((value, ctx) => {
114
+ if (!value.cashReceiptOn || !options.cashReceiptOptionKeys.has(value.paymentOption)) return;
115
+ const parsed = cashReceiptRequestSchema.safeParse({
116
+ type: value.cashReceiptType,
117
+ identityNumber: value.cashReceiptNumber ?? "",
118
+ });
119
+ if (!parsed.success) {
120
+ ctx.addIssue({
121
+ code: "custom",
122
+ path: ["cashReceiptNumber"],
123
+ message: parsed.error.issues[0]?.message ?? "현금영수증 번호를 확인해 주십시오.",
124
+ });
125
+ }
126
+ });
127
+ }
128
+
129
+ export type CheckoutFormSchema = ReturnType<typeof checkoutFormSchema>;
130
+ export type CheckoutFormInput = z.input<CheckoutFormSchema>;
131
+ export type CheckoutForm = z.output<CheckoutFormSchema>;
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { guestLookupLockedMessage } from "./guest-order";
3
+
4
+ describe("guestLookupLockedMessage", () => {
5
+ it("남은 시간을 분 단위로 올려 알린다", () => {
6
+ expect(guestLookupLockedMessage(1800)).toBe(
7
+ "조회 시도가 너무 많습니다. 30분 뒤에 다시 시도해 주십시오",
8
+ );
9
+ expect(guestLookupLockedMessage(61)).toContain("2분 뒤");
10
+ });
11
+
12
+ it("남은 시간을 모르면 잠시 후로 안내한다", () => {
13
+ expect(guestLookupLockedMessage(undefined)).toBe(
14
+ "조회 시도가 너무 많습니다. 잠시 후 다시 시도해 주십시오",
15
+ );
16
+ expect(guestLookupLockedMessage("30")).toContain("잠시 후");
17
+ });
18
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 비회원 주문 조회 잠금 안내 — API는 주문번호마다 시도 한도를 두고, 넘으면 429 `TOO_MANY_REQUESTS`와
3
+ * 남은 시간(`details.retryAfterSeconds`)을 준다. 남은 시간은 분 단위로 올려 알린다.
4
+ */
5
+ export function guestLookupLockedMessage(retryAfterSeconds: unknown): string {
6
+ const minutes =
7
+ typeof retryAfterSeconds === "number" && retryAfterSeconds > 0
8
+ ? Math.ceil(retryAfterSeconds / 60)
9
+ : null;
10
+ return minutes
11
+ ? `조회 시도가 너무 많습니다. ${minutes}분 뒤에 다시 시도해 주십시오`
12
+ : "조회 시도가 너무 많습니다. 잠시 후 다시 시도해 주십시오";
13
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { orderItemStatusLabel, withdrawalRestrictedLabel } from "./order-status";
3
+
4
+ describe("주문 상품 상태 라벨", () => {
5
+ it("배송 상품은 배송 단계로 보인다", () => {
6
+ expect(orderItemStatusLabel("DELIVERED")).toBe("배송 완료");
7
+ expect(orderItemStatusLabel("DELIVERED", "SHIPPING")).toBe("배송 완료");
8
+ expect(orderItemStatusLabel("CONFIRMED", "SHIPPING")).toBe("발송 준비");
9
+ });
10
+
11
+ it("배송이 없는 상품의 DELIVERED는 제공 완료, 방문 수령은 수령 완료다", () => {
12
+ expect(orderItemStatusLabel("DELIVERED", "SERVICE")).toBe("제공 완료");
13
+ expect(orderItemStatusLabel("DELIVERED", "PICKUP")).toBe("수령 완료");
14
+ expect(orderItemStatusLabel("CONFIRMED", "SERVICE")).toBe("준비 중");
15
+ expect(orderItemStatusLabel("PAID", "PICKUP")).toBe("결제 완료");
16
+ });
17
+
18
+ it("모르는 상태는 코드 그대로다", () => {
19
+ expect(orderItemStatusLabel("ON_HOLD", "SERVICE")).toBe("ON_HOLD");
20
+ });
21
+ });
22
+
23
+ describe("청약철회 제한 표시", () => {
24
+ it("제한 주문상품에만 라벨을 붙인다", () => {
25
+ expect(withdrawalRestrictedLabel({ withdrawalRestricted: true })).toBe("청약철회 제한 상품");
26
+ expect(withdrawalRestrictedLabel({ withdrawalRestricted: false })).toBeNull();
27
+ expect(withdrawalRestrictedLabel({})).toBeNull();
28
+ });
29
+ });
@@ -1,4 +1,4 @@
1
- import type { OrderItemStatus } from "@sayren/storefront-sdk";
1
+ import { isShippingFulfillment, type OrderItemStatus } from "@sayren/storefront-sdk";
2
2
 
3
3
  /** 주문 상품 상태 라벨 — 화면에 코드 대신 보인다. 서버가 상태를 더하면 코드 그대로 보인다 */
4
4
  const ORDER_ITEM_STATUS_LABELS: Record<OrderItemStatus, string> = {
@@ -12,6 +12,32 @@ const ORDER_ITEM_STATUS_LABELS: Record<OrderItemStatus, string> = {
12
12
  CANCELED: "취소",
13
13
  };
14
14
 
15
- export function orderItemStatusLabel(status: string): string {
15
+ /**
16
+ * 배송이 없는 상품(이용권·방문 수령 등)의 상태 라벨. 제공 완료는 `DELIVERED`를 그대로 쓰므로 표시만 바꾼다 —
17
+ * 방문 수령은 「수령 완료」, 그 밖의 유형은 「제공 완료」다.
18
+ */
19
+ function nonShippingLabel(status: string, fulfillmentType: string): string | null {
20
+ if (status === "CONFIRMED") return "준비 중";
21
+ if (status === "DELIVERED") return fulfillmentType === "PICKUP" ? "수령 완료" : "제공 완료";
22
+ return null;
23
+ }
24
+
25
+ export function orderItemStatusLabel(status: string, fulfillmentType?: string | null): string {
26
+ if (fulfillmentType && !isShippingFulfillment(fulfillmentType)) {
27
+ const label = nonShippingLabel(status, fulfillmentType);
28
+ if (label) return label;
29
+ }
16
30
  return ORDER_ITEM_STATUS_LABELS[status as OrderItemStatus] ?? status;
17
31
  }
32
+
33
+ /**
34
+ * 청약철회 제한 주문상품(이슈 #48) — 구매자가 결제 전에 동의했고, 제공이 시작되면(첫 다운로드·열람) 단순 변심 취소·반품을
35
+ * 받지 않는다. 주문 상세·비회원 주문 조회가 상품 옆에 붙인다.
36
+ */
37
+ export function withdrawalRestrictedLabel(item: { withdrawalRestricted?: boolean }): string | null {
38
+ return item.withdrawalRestricted ? "청약철회 제한 상품" : null;
39
+ }
40
+
41
+ /** 제공이 시작된 청약철회 제한 상품의 취소·반품 접수 거절(409 `WITHDRAWAL_RESTRICTED`) 안내 */
42
+ export const WITHDRAWAL_RESTRICTED_CLAIM_MESSAGE =
43
+ "제공이 시작된 상품이라 취소·반품을 접수할 수 없습니다.";
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { pageTitle } from "./page-title";
3
+
4
+ const root = { loaderData: { store: { name: "나의첫번째몰", logoUrl: null } } };
5
+
6
+ describe("pageTitle", () => {
7
+ it("화면 이름 뒤에 스토어 이름을 붙인다", () => {
8
+ expect(pageTitle([root, { loaderData: {} }], "회원가입")).toBe("회원가입 | 나의첫번째몰");
9
+ });
10
+
11
+ it("화면 이름이 없으면 스토어 이름만 쓴다", () => {
12
+ expect(pageTitle([root])).toBe("나의첫번째몰");
13
+ });
14
+
15
+ it("스토어 이름을 읽지 못하면 기본 이름을 쓴다", () => {
16
+ expect(pageTitle([{ loaderData: { store: null } }], "장바구니")).toBe("장바구니 | 스토어");
17
+ expect(pageTitle([], "장바구니")).toBe("장바구니 | 스토어");
18
+ });
19
+ });
@@ -0,0 +1,23 @@
1
+ /** 스토어 이름을 읽지 못했을 때 탭 제목 */
2
+ export const FALLBACK_STORE_NAME = "스토어";
3
+
4
+ /** 루트 라우트 loader가 내려 주는 스토어 이름 — `__root.tsx`의 `RootData.store` */
5
+ function storeNameOf(matches: ReadonlyArray<{ loaderData?: unknown }>): string | null {
6
+ const root = matches[0]?.loaderData as { store?: { name?: unknown } | null } | undefined;
7
+ const name = root?.store?.name;
8
+ return typeof name === "string" && name.trim() ? name : null;
9
+ }
10
+
11
+ /**
12
+ * 탭 제목 — `{화면} | {스토어 이름}`. 홈처럼 화면 이름이 없으면 스토어 이름만 쓴다.
13
+ * 라우트 `head`의 `matches`를 그대로 넘긴다(루트 loader가 스토어 이름을 싣는다).
14
+ *
15
+ * 확장 지점 — 구분자나 순서를 바꾸려면 여기만 고친다.
16
+ */
17
+ export function pageTitle(
18
+ matches: ReadonlyArray<{ loaderData?: unknown }>,
19
+ screen?: string | null,
20
+ ): string {
21
+ const store = storeNameOf(matches) ?? FALLBACK_STORE_NAME;
22
+ return screen ? `${screen} | ${store}` : store;
23
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseProductsSearch } from "./products-search";
3
+
4
+ describe("상품 목록 URL 상태", () => {
5
+ it("기본값(1페이지·추천순)과 빈 값은 비운다", () => {
6
+ expect(parseProductsSearch({ page: 1, sort: "recommend", keyword: " " })).toEqual({
7
+ keyword: undefined,
8
+ categoryId: undefined,
9
+ sort: undefined,
10
+ page: undefined,
11
+ });
12
+ });
13
+
14
+ it("라우터가 숫자로 푼 값도 문자열로 다시 읽는다", () => {
15
+ expect(
16
+ parseProductsSearch({ keyword: 123, page: 2, sort: "latest", categoryId: "cat_1" }),
17
+ ).toEqual({ keyword: "123", categoryId: "cat_1", sort: "latest", page: 2 });
18
+ });
19
+
20
+ it("잘못된 정렬·페이지는 오류 대신 기본값으로 읽는다", () => {
21
+ const search = parseProductsSearch({ sort: "cheapest", page: "abc" });
22
+ expect(search.sort).toBeUndefined();
23
+ expect(search.page).toBeUndefined();
24
+ });
25
+ });
@@ -0,0 +1,49 @@
1
+ import { type ProductSort, productSortSchema } from "@sayren/storefront-sdk";
2
+ import { createLoader, parseAsInteger, parseAsString, parseAsStringLiteral } from "nuqs";
3
+
4
+ /**
5
+ * 상품 목록의 URL 상태(검색어·카테고리·정렬·페이지) — nuqs 파서가 원천이다.
6
+ *
7
+ * 라우트의 `validateSearch`(서버·브라우저 공통, loader 입력)와 화면의 `useQueryStates`(검색·정렬 변경)가 같은
8
+ * 파서를 쓴다. 조건이 URL에 있으니 공유·새로고침·뒤로가기가 그대로 동작한다. 기본값(1페이지·추천순)은 주소에서 뺀다.
9
+ */
10
+ export const productsSearchParams = {
11
+ keyword: parseAsString,
12
+ categoryId: parseAsString,
13
+ // 정렬 값은 SDK 스키마가 원천이다. 기본값은 서버 기본값과 같은 추천순이다
14
+ sort: parseAsStringLiteral(productSortSchema.options).withDefault("recommend"),
15
+ page: parseAsInteger.withDefault(1),
16
+ };
17
+
18
+ const loadProductsSearch = createLoader(productsSearchParams);
19
+
20
+ /** 라우트가 쓰는 검색 조건. 기본값인 항목은 비워 두어 링크가 주소에 싣지 않는다 */
21
+ export interface ProductsSearch {
22
+ keyword?: string;
23
+ categoryId?: string;
24
+ sort?: ProductSort;
25
+ page?: number;
26
+ }
27
+
28
+ /**
29
+ * 주소의 검색 파라미터를 읽는다. 잘못된 값(없는 정렬, 숫자가 아닌 페이지)은 오류 화면 대신 기본값으로 읽는다.
30
+ * 라우터는 `?page=2`를 숫자로, `?keyword=%22a%22`를 문자열로 먼저 풀어 두므로 nuqs 로더가 다시 문자열로 읽는다.
31
+ */
32
+ export function parseProductsSearch(raw: Record<string, unknown>): ProductsSearch {
33
+ const { keyword, categoryId, sort, page } = loadProductsSearch(raw as Record<string, string>);
34
+ return {
35
+ keyword: keyword?.trim() || undefined,
36
+ categoryId: categoryId || undefined,
37
+ sort: sort === "recommend" ? undefined : sort,
38
+ page: page > 1 ? page : undefined,
39
+ };
40
+ }
41
+
42
+ export const SORT_LABELS: Record<ProductSort, string> = {
43
+ recommend: "추천순",
44
+ latest: "최신순",
45
+ priceAsc: "낮은 가격순",
46
+ priceDesc: "높은 가격순",
47
+ reviewCount: "리뷰 많은순",
48
+ ratingDesc: "평점 높은순",
49
+ };
@@ -1,13 +1,38 @@
1
+ import { QueryClient } from "@tanstack/react-query";
1
2
  import { createRouter } from "@tanstack/react-router";
3
+ import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
2
4
  import { routeTree } from "./routeTree.gen";
3
5
 
6
+ /** 라우트 loader가 받는 문맥 — loader가 `queryClient.ensureQueryData`로 미리 받아 둔다 */
7
+ export interface RouterContext {
8
+ queryClient: QueryClient;
9
+ }
10
+
4
11
  export function getRouter() {
5
- return createRouter({
12
+ // 요청마다 새로 만든다 — 서버 렌더에서 다른 구매자의 캐시가 섞이지 않는다
13
+ const queryClient = new QueryClient({
14
+ defaultOptions: {
15
+ queries: {
16
+ // 서버가 받아 둔 데이터를 하이드레이션 직후 다시 부르지 않는다
17
+ staleTime: 30_000,
18
+ refetchOnWindowFocus: false,
19
+ retry: 1,
20
+ },
21
+ },
22
+ });
23
+
24
+ const router = createRouter({
6
25
  routeTree,
26
+ context: { queryClient } satisfies RouterContext,
7
27
  scrollRestoration: true,
8
28
  // 주문서는 들어올 때마다 세션을 만든다. 마우스를 올린 것만으로 만들지 않도록 미리 불러오기를 끈다
9
29
  defaultPreload: false,
10
30
  });
31
+
32
+ // 서버에서 채운 쿼리 캐시를 HTML에 실어 보내고 브라우저에서 되살린다(QueryClientProvider도 여기서 감싼다)
33
+ setupRouterSsrQueryIntegration({ router, queryClient });
34
+
35
+ return router;
11
36
  }
12
37
 
13
38
  declare module "@tanstack/react-router" {
@@ -1,5 +1,5 @@
1
1
  import {
2
- createRootRoute,
2
+ createRootRouteWithContext,
3
3
  type ErrorComponentProps,
4
4
  HeadContent,
5
5
  Link,
@@ -8,11 +8,14 @@ import {
8
8
  useRouterState,
9
9
  } from "@tanstack/react-router";
10
10
  import { createServerFn } from "@tanstack/react-start";
11
+ import { NuqsAdapter } from "nuqs/adapters/tanstack-router";
11
12
  import { useEffect } from "react";
12
13
  import { SiteHeader, type StoreBrand } from "../components/site-header";
13
14
  import { type AnalyticsConfig, startAnalytics } from "../lib/analytics";
14
15
  import { apiFor } from "../lib/api.server";
15
16
  import { ANALYTICS_DEBUG, API_BASE_URL, resolveStoreCode } from "../lib/config.server";
17
+ import { FALLBACK_STORE_NAME } from "../lib/page-title";
18
+ import type { RouterContext } from "../router";
16
19
  import appCss from "../styles.css?url";
17
20
 
18
21
  /** 방문 분석 설정 — 서버 값(`process.env`)이라 서버 함수로 내려준다 */
@@ -42,13 +45,14 @@ interface RootData {
42
45
  store: StoreBrand | null;
43
46
  }
44
47
 
45
- export const Route = createRootRoute({
48
+ export const Route = createRootRouteWithContext<RouterContext>()({
46
49
  head: ({ loaderData }) => ({
47
50
  meta: [
48
51
  { charSet: "utf-8" },
49
52
  { name: "viewport", content: "width=device-width, initial-scale=1" },
50
- // 화면마다 제목을 덮어쓴다. 여기 값은 제목을 정하지 않은 화면의 기본값이다
51
- { title: loaderData?.store?.name ?? "스토어" },
53
+ // 화면마다 `{화면} | {스토어 이름}`으로 덮어쓴다(`lib/page-title.ts`). 여기 값은 홈처럼
54
+ // 화면 이름이 없는 곳의 제목이다
55
+ { title: loaderData?.store?.name ?? FALLBACK_STORE_NAME },
52
56
  ],
53
57
  links: [
54
58
  { rel: "stylesheet", href: appCss },
@@ -79,8 +83,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
79
83
  <HeadContent />
80
84
  </head>
81
85
  <body className="min-h-screen bg-white text-ink">
82
- <SiteHeader store={store} />
83
- <main className="mx-auto w-full max-w-5xl px-4 py-8">{children}</main>
86
+ {/* URL 검색 파라미터(목록 검색어·카테고리·정렬·페이지)는 nuqs로 읽고 바꾼다 */}
87
+ <NuqsAdapter>
88
+ <SiteHeader store={store} />
89
+ <main className="mx-auto w-full max-w-5xl px-4 py-8">{children}</main>
90
+ </NuqsAdapter>
84
91
  <Scripts />
85
92
  </body>
86
93
  </html>
@@ -11,6 +11,7 @@ import { z } from "zod";
11
11
  import { SubmitButton } from "../components/submit-button";
12
12
  import { apiFor, authFor } from "../lib/api.server";
13
13
  import { formatDateTime } from "../lib/format";
14
+ import { pageTitle } from "../lib/page-title";
14
15
  import { readToken, signOut } from "../lib/session.server";
15
16
  import { SOCIAL_BUTTONS } from "../lib/social-buttons";
16
17
  import { writeSocialLogin } from "../lib/social-login.server";
@@ -181,7 +182,7 @@ function accountErrorMessage(error: unknown): string {
181
182
  export const Route = createFileRoute("/account")({
182
183
  validateSearch: accountSearch,
183
184
  loader: () => getAccount(),
184
- head: () => ({ meta: [{ title: "내 정보" }] }),
185
+ head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "내 정보") }] }),
185
186
  component: Account,
186
187
  });
187
188