Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const SettingsNavContainer = () => {
const settingsNavItems = [
{ label: tSettings("nav.account"), tab: "account" as const },
{ label: tSettings("nav.policy"), tab: "policy" as const },
{ label: tSettings("nav.privacy"), tab: "privacy" as const },
{ label: tSettings("nav.withdrawal"), tab: "withdrawal" as const },
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,27 @@
import { SettingsProfileContainer } from "@/app/[locale]/(main)/settings/_containers/SettingsProfileContainer";
import { useSettingsTab } from "@/app/[locale]/(main)/settings/_hooks/useSettingsTab";
import { SettingsPolicyContainer } from "@/app/[locale]/(main)/settings/policy/_containers/SettingsPolicyContainer";
import { SettingsPrivacyContainer } from "@/app/[locale]/(main)/settings/privacy/_containers/SettingsPrivacyContainer";
import { SettingsWithdrawalContainer } from "@/app/[locale]/(main)/settings/withdrawal/_containers/SettingsWithdrawalContainer";
import { AsyncBoundary } from "@/components/boundary/AsyncBoundary";

export const SettingsTabsContainer = () => {
const tab = useSettingsTab();

if (tab === "policy") return <SettingsPolicyContainer />;
if (tab === "policy") {
return (
<AsyncBoundary>
<SettingsPolicyContainer />
</AsyncBoundary>
);
}
if (tab === "privacy") {
return (
<AsyncBoundary>
<SettingsPrivacyContainer />
</AsyncBoundary>
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if (tab === "withdrawal") return <SettingsWithdrawalContainer />;
return <SettingsProfileContainer />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

import { useSearchParams } from "next/navigation";

export type SettingsTab = "account" | "policy" | "withdrawal";
export type SettingsTab = "account" | "policy" | "privacy" | "withdrawal";

const SETTINGS_TAB_PARAM = "tab";

const isSettingsTab = (
value: string | null,
): value is Exclude<SettingsTab, "account"> =>
value === "policy" || value === "withdrawal";
value === "policy" || value === "privacy" || value === "withdrawal";

export const useSettingsTab = (): SettingsTab => {
const searchParams = useSearchParams();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
"use client";

import { useTranslations } from "next-intl";

import { PolicyDocument } from "@/components/policy/PolicyDocument";
import { useTerms } from "@/queries/use-terms";

export const SettingsPolicyContainer = () => {
const t = useTranslations("Policy");
const { data: terms } = useTerms("SERVICE");
const term = terms[0];

return (
<div className="flex flex-col gap-5 px-15 pt-7.5">
<h1 className="typo-headline-m-16 text-timo-gray-900">약관</h1>
<hr className="border-timo-gray-500" />
<div className="px-15 pt-7.5">
{term ? (
<PolicyDocument title={term.title} content={term.content} />
) : (
<p className="typo-body-m-12 text-timo-gray-700">{t("empty")}</p>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"use client";

import { useTranslations } from "next-intl";

import { PolicyDocument } from "@/components/policy/PolicyDocument";
import { useTerms } from "@/queries/use-terms";

export const SettingsPrivacyContainer = () => {
const t = useTranslations("Policy");
const { data: terms } = useTerms("PRIVACY");
const term = terms[0];

return (
<div className="px-15 pt-7.5">
{term ? (
<PolicyDocument title={term.title} content={term.content} />
) : (
<p className="typo-body-m-12 text-timo-gray-700">{t("empty")}</p>
)}
</div>
);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

PolicyContainer와 로직 중복 — 공통 추출을 제안합니다

SettingsPrivacyContainer는 PolicyContainer와 동일한 패턴을 따릅니다: useTerms 호출 → term 선택 → PolicyDocument 또는 빈 상태 렌더링. 두 컨테이너 모두 useTranslations("Policy")와 t("empty")를 사용합니다.

이 패턴을 공통 컴포넌트로 추출하면 유지보수성이 향상됩니다. 예를 들어 PolicyContent 컴포넌트를 만들어 type과 headingLevel을 prop으로 받도록 하면, 두 컨테이너에서 각각 재사용할 수 있습니다.

♻️ 공통 PolicyContent 컴포넌트 추출 제안
// components/policy/PolicyContent.tsx
"use client";

import { useTranslations } from "next-intl";

import type { TermsType } from "`@/types/terms-type`";
import { PolicyDocument } from "`@/components/policy/PolicyDocument`";
import { useTerms } from "`@/queries/use-terms`";

export interface PolicyContentProps {
  type: TermsType;
  headingLevel?: "h1" | "h2";
}

export const PolicyContent = ({ type, headingLevel = "h1" }: PolicyContentProps) => {
  const t = useTranslations("Policy");
  const { data: terms } = useTerms(type);
  const term = terms.find((item) => item.type === type);

  if (!term) {
    return <p className="typo-body-m-12 text-timo-gray-700">{t("empty")}</p>;
  }

  return <PolicyDocument title={term.title} content={term.content} headingLevel={headingLevel} />;
};

그 후 SettingsPrivacyContainer는 단순화:

 "use client";

-import { useTranslations } from "next-intl";
-
-import { PolicyDocument } from "`@/components/policy/PolicyDocument`";
-import { useTerms } from "`@/queries/use-terms`";
+import { PolicyContent } from "`@/components/policy/PolicyContent`";

 export const SettingsPrivacyContainer = () => {
-  const t = useTranslations("Policy");
-  const { data: terms } = useTerms("PRIVACY");
-  const term = terms[0];
-
   return (
     <div className="px-15 pt-7.5">
-      {term ? (
-        <PolicyDocument title={term.title} content={term.content} />
-      ) : (
-        <p className="typo-body-m-12 text-timo-gray-700">{t("empty")}</p>
-      )}
+      <PolicyContent type="PRIVACY" headingLevel="h2" />
     </div>
   );
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/timo-web/app/`[locale]/(main)/settings/privacy/_containers/SettingsPrivacyContainer.tsx
around lines 8 - 22, Extract the duplicated useTerms, translation,
term-selection, and empty-state rendering from SettingsPrivacyContainer and
PolicyContainer into a shared PolicyContent component. Make PolicyContent accept
the terms type and heading level as props, preserve the existing PolicyDocument
rendering and Policy empty-state translation, then update both containers to
delegate to it.

24 changes: 16 additions & 8 deletions apps/timo-web/app/[locale]/login/_containers/LoginContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { useTranslations } from "next-intl";

import { OnboardingGoogleButtonContainer } from "@/app/[locale]/onboarding/_containers/OnboardingGoogleButtonContainer";
import { LottiePlayer } from "@/components/lottie/LottiePlayer";
import { ROUTES } from "@/constants/routes";
import { Link } from "@/i18n/navigation";

export const LoginContainer = () => {
const t = useTranslations("Login");
Expand Down Expand Up @@ -51,19 +53,25 @@ export const LoginContainer = () => {

<p className="typo-body-r-12 text-timo-gray-700 text-center">
{t.rich("termsNotice", {
// TODO: 실제 이용약관 페이지 URL 나오면 href 교체
terms: (chunks) => (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a href="#" className="underline">
<Link
href={`${ROUTES.POLICY}?type=SERVICE`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{chunks}
</a>
</Link>
),
// TODO: 실제 개인정보 처리방침 페이지 URL 나오면 href 교체
privacy: (chunks) => (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a href="#" className="underline">
<Link
href={`${ROUTES.POLICY}?type=PRIVACY`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{chunks}
</a>
</Link>
),
})}
</p>
Expand Down
24 changes: 24 additions & 0 deletions apps/timo-web/app/[locale]/policy/_containers/PolicyContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client";

import { useTranslations } from "next-intl";

import type { TermsType } from "@/types/terms-type";

import { PolicyDocument } from "@/components/policy/PolicyDocument";
import { useTerms } from "@/queries/use-terms";

export interface PolicyContainerProps {
type: TermsType;
}

export const PolicyContainer = ({ type }: PolicyContainerProps) => {
const t = useTranslations("Policy");
const { data: terms } = useTerms(type);
const term = terms.find((item) => item.type === type) ?? terms[0];

if (!term) {
return <p className="typo-body-m-12 text-timo-gray-700">{t("empty")}</p>;
}

return <PolicyDocument title={term.title} content={term.content} />;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
36 changes: 36 additions & 0 deletions apps/timo-web/app/[locale]/policy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import timoTextLogo from "@repo/timo-design-system/assets/images/logo/timo-text-logo.svg";
import Image from "next/image";

import type { TermsType } from "@/types/terms-type";

import { PolicyContainer } from "@/app/[locale]/policy/_containers/PolicyContainer";
import { AsyncBoundary } from "@/components/boundary/AsyncBoundary";
import { ROUTES } from "@/constants/routes";
import { Link } from "@/i18n/navigation";

const isTermsType = (value: string | undefined): value is TermsType =>
value === "SERVICE" || value === "PRIVACY";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

interface PolicyPageProps {
searchParams: Promise<{ type?: string }>;
}

export default async function PolicyPage({ searchParams }: PolicyPageProps) {
const { type } = await searchParams;

return (
<div className="flex min-h-screen w-full flex-col items-center gap-12.5 bg-white">
<div className="flex w-full items-center justify-between px-37 py-6.75">
<Link href={ROUTES.LOGIN}>
<Image src={timoTextLogo} alt="Timo" width={92} height={35} />
</Link>
</div>

<div className="flex w-full justify-center px-8 pb-20">
<AsyncBoundary>
<PolicyContainer type={isTermsType(type) ? type : "SERVICE"} />
</AsyncBoundary>
</div>
</div>
);
}
20 changes: 20 additions & 0 deletions apps/timo-web/components/policy/PolicyDocument.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export interface PolicyDocumentProps {
title: string;
content: string;
}

export const PolicyDocument = ({ title, content }: PolicyDocumentProps) => {
return (
<div className="flex w-full max-w-[1147px] flex-col gap-7.5">
<div className="flex flex-col gap-5">
<h1 className="typo-headline-b-16 text-timo-gray-900 w-full">
{title}
</h1>
<hr className="border-timo-gray-500 w-full" />
</div>
<p className="typo-body-m-12 text-timo-gray-900 whitespace-pre-wrap">
{content}
</p>
</div>
);
};
1 change: 1 addition & 0 deletions apps/timo-web/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export const ROUTES = {
FOCUS: "/focus",
STATISTICS: "/statistics",
SETTINGS: "/settings",
POLICY: "/policy",
} as const;
6 changes: 5 additions & 1 deletion apps/timo-web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
"Settings": {
"nav": {
"account": "Account",
"policy": "Terms & Policies",
"policy": "Terms of Service",
"privacy": "Privacy Policy",
"withdrawal": "Delete account"
},
"profile": {
Expand Down Expand Up @@ -226,5 +227,8 @@
"hourUnit": "h",
"minuteUnit": "m"
}
},
"Policy": {
"empty": "This policy content isn't available yet."
}
}
4 changes: 4 additions & 0 deletions apps/timo-web/messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"nav": {
"account": "개인 정보",
"policy": "약관",
"privacy": "개인 정보 처리 방침",
"withdrawal": "회원 탈퇴"
},
"profile": {
Expand Down Expand Up @@ -223,5 +224,8 @@
"hourUnit": "시간",
"minuteUnit": "분"
}
},
"Policy": {
"empty": "약관 내용을 아직 준비 중입니다."
}
}
19 changes: 19 additions & 0 deletions apps/timo-web/queries/use-terms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";

import { useSuspenseQuery } from "@tanstack/react-query";

import {
getGetTermsQueryKey,
getTerms,
} from "@/api/generated/endpoints/terms/terms";
import { termsListSchema, type TermsType } from "@/types/terms-type";

export const useTerms = (type?: TermsType) => {
const params = type ? { type } : undefined;

return useSuspenseQuery({
queryKey: getGetTermsQueryKey(params),
queryFn: ({ signal }) => getTerms(params, undefined, signal),
select: ({ data }) => termsListSchema.parse(data).terms,
});
};
17 changes: 17 additions & 0 deletions apps/timo-web/types/terms-type.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 스키마가 api/generated/endpoints/terms/terms.zod.ts의 GetTermsResponse랑 내용이 겹치는 것 같아서 새로 만들 필요 없이 가져다 쓰면 좋을 것 같아요! 확인 부탁드립니다

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

terms.zod.ts의 GetTermsResponse는 type을 그냥 zod.string()으로 생성해서 "SERVICE" | "PRIVACY" 리터럴 타입을 잃게 돼요. 이 리터럴 타입은 PolicyContainer/SettingsTermsContainer의 type prop, useTerms(type?: TermsType), page.tsx의 termsTypeSchema.safeParse(type) 쿼리 파라미터 검증까지 앱 전반에서 실제로 쓰이고 있어서, 이 타입 좁히기 자체가 terms-type.ts가 존재하는 이유입니다.

또한 GetTermsResponse는 status/message/data 래퍼 안에 terms 배열이 인라인으로 정의되어 있고, 아이템 단위 스키마가 별도로 export되어 있지 않아요. 재사용하려면 GetTermsResponse.shape.data.unwrap().shape.terms.element 같은 방식으로 생성 파일 내부 구조에 직접 접근해야 하는데, 이 파일은 "Do not edit manually" 주석이 붙은 orval 자동 생성 파일이라 재생성 시 구조가 바뀌면 조용히 깨질 수 있습니다.

그래서 termsItemSchema/termsListSchema 를 통째로 제거하기보다는, termsTypeSchema(리터럴 enum)는 그대로 유지하는 게 맞다고 생각이들어요.

혜원님은 어떻게 생각하시나요?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하 설명 감사합니다! ✌🏻
생성된 스키마를 재사용하는 쪽만 생각했는데 TermsType의 리터럴 타입과 orval 내부 구조 의존성까지 생각하면 지금처럼 별도 스키마를 유지하는 게 맞는 것 같습니다!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TermsTypeSchema 부분만 별도로 사용하면 될 것 같아요~

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { z } from "zod";

export const termsTypeSchema = z.enum(["SERVICE", "PRIVACY"]);

export const termsItemSchema = z.object({
termsId: z.number(),
type: termsTypeSchema,
title: z.string(),
content: z.string(),
});

export const termsListSchema = z.object({
terms: z.array(termsItemSchema),
});

export type TermsType = z.infer<typeof termsTypeSchema>;
export type TermsItem = z.infer<typeof termsItemSchema>;
Loading