diff --git a/apps/app/app/(main)/browser/_hooks/useBrowserState.ts b/apps/app/app/(main)/browser/_hooks/useBrowserState.ts index 2228e1a68..fd9b3a8f4 100644 --- a/apps/app/app/(main)/browser/_hooks/useBrowserState.ts +++ b/apps/app/app/(main)/browser/_hooks/useBrowserState.ts @@ -503,7 +503,23 @@ export function useBrowserState({ ); const data = await response.json(); if (!response.ok) { - setAiError(data.error ?? "요청에 실패했어요"); + if (response.status === 401) { + setAiError( + "로그인 상태를 확인해 주세요. 작성 중인 메모는 그대로 유지돼요.", + ); + } else if (response.status === 402 || response.status === 403) { + setAiError( + "현재 계정 또는 앱에서는 이 AI 기능을 사용할 수 없어요. 일반 메모는 계속 사용할 수 있어요.", + ); + } else if (response.status === 429) { + setAiError( + "AI 이용 한도에 도달했어요. 일반 메모는 계속 사용할 수 있어요.", + ); + } else { + setAiError( + "AI 요청을 완료하지 못했어요. 작성 중인 메모는 그대로 유지돼요.", + ); + } return; } if (question) { diff --git a/apps/app/lib/storage/syncService.test.ts b/apps/app/lib/storage/syncService.test.ts new file mode 100644 index 000000000..acdea9405 --- /dev/null +++ b/apps/app/lib/storage/syncService.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getMemoByUrl: vi.fn(), + insertMemo: vi.fn(), + updateMemo: vi.fn(), + getUnsyncedMemos: vi.fn(), + markAsSynced: vi.fn(), + clearSyncedMemos: vi.fn(), +})); +vi.mock("@/lib/supabase/client", () => ({ + supabase: { auth: { getSession: mocks.getSession } }, +})); +vi.mock("@web-memo/shared/utils/services", () => ({ + MemoService: class { + getMemoByUrl = mocks.getMemoByUrl; + insertMemo = mocks.insertMemo; + updateMemo = mocks.updateMemo; + }, +})); +vi.mock("./localMemo", () => ({ + getUnsyncedMemos: mocks.getUnsyncedMemos, + markAsSynced: mocks.markAsSynced, + clearSyncedMemos: mocks.clearSyncedMemos, +})); + +import { syncMemosToSupabase } from "./syncService"; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getSession.mockResolvedValue({ + data: { session: { user: { id: "user" } } }, + }); + mocks.getUnsyncedMemos.mockResolvedValue([ + { + id: "local-1", + url: "https://example.com", + title: "Draft", + memo: "Keep this draft", + }, + ]); + mocks.getMemoByUrl.mockResolvedValue({ data: [], error: null }); + mocks.insertMemo.mockResolvedValue({ data: [{ id: 1 }], error: null }); +}); + +describe("local memo synchronization", () => { + it("keeps drafts unsynced when the free quota rejects an insert", async () => { + mocks.insertMemo.mockResolvedValue({ + data: null, + error: { message: "FREE_MEMO_LIMIT_REACHED" }, + }); + expect(await syncMemosToSupabase()).toEqual({ synced: 0, failed: 1 }); + expect(mocks.markAsSynced).not.toHaveBeenCalled(); + expect(mocks.clearSyncedMemos).not.toHaveBeenCalled(); + }); + it("does not treat a failed lookup as permission to insert", async () => { + mocks.getMemoByUrl.mockResolvedValue({ + data: null, + error: { message: "offline" }, + }); + expect(await syncMemosToSupabase()).toEqual({ synced: 0, failed: 1 }); + expect(mocks.insertMemo).not.toHaveBeenCalled(); + }); + it("requires returned server rows before marking a draft synced", async () => { + mocks.insertMemo.mockResolvedValue({ data: [], error: null }); + expect(await syncMemosToSupabase()).toEqual({ synced: 0, failed: 1 }); + expect(mocks.markAsSynced).not.toHaveBeenCalled(); + }); + it("keeps the local copy even after a confirmed server save", async () => { + expect(await syncMemosToSupabase()).toEqual({ synced: 1, failed: 0 }); + expect(mocks.markAsSynced).toHaveBeenCalledWith(["local-1"]); + expect(mocks.clearSyncedMemos).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/lib/storage/syncService.ts b/apps/app/lib/storage/syncService.ts index 87e477a45..570f2cc92 100644 --- a/apps/app/lib/storage/syncService.ts +++ b/apps/app/lib/storage/syncService.ts @@ -1,20 +1,25 @@ import { MemoService } from "@web-memo/shared/utils/services"; import { supabase } from "@/lib/supabase/client"; -import { clearSyncedMemos, getUnsyncedMemos, markAsSynced } from "./localMemo"; +import { getUnsyncedMemos, markAsSynced } from "./localMemo"; const memoService = new MemoService(supabase); -export async function syncMemosToSupabase(): Promise<{ +/** 서버 저장을 확인한 항목만 동기화로 표시하고 로컬 사본을 삭제하지 않습니다. */ +export const syncMemosToSupabase = async (): Promise<{ synced: number; failed: number; -}> { +}> => { const { data: { session }, } = await supabase.auth.getSession(); - if (!session) return { synced: 0, failed: 0 }; + if (!session) { + return { synced: 0, failed: 0 }; + } const unsynced = await getUnsyncedMemos(); - if (unsynced.length === 0) return { synced: 0, failed: 0 }; + if (unsynced.length === 0) { + return { synced: 0, failed: 0 }; + } const syncedIds: string[] = []; let failed = 0; @@ -22,9 +27,12 @@ export async function syncMemosToSupabase(): Promise<{ for (const memo of unsynced) { try { const existing = await memoService.getMemoByUrl(memo.url); + if (existing.error) { + throw existing.error; + } if (existing.data && existing.data.length > 0) { - await memoService.updateMemo({ + const result = await memoService.updateMemo({ id: existing.data[0].id, request: { url: memo.url, @@ -36,8 +44,11 @@ export async function syncMemosToSupabase(): Promise<{ isWish: memo.isWish ?? existing.data[0].isWish, }, }); + if (result.error || !result.data?.length) { + throw result.error ?? new Error("MEMO_SYNC_NOT_CONFIRMED"); + } } else { - await memoService.insertMemo({ + const result = await memoService.insertMemo({ url: memo.url, title: memo.title, memo: memo.memo, @@ -46,6 +57,9 @@ export async function syncMemosToSupabase(): Promise<{ favIconUrl: memo.favIconUrl ?? null, isWish: memo.isWish ?? false, }); + if (result.error || !result.data?.length) { + throw result.error ?? new Error("MEMO_SYNC_NOT_CONFIRMED"); + } } syncedIds.push(memo.id); @@ -56,8 +70,7 @@ export async function syncMemosToSupabase(): Promise<{ if (syncedIds.length > 0) { await markAsSynced(syncedIds); - await clearSyncedMemos(); } return { synced: syncedIds.length, failed }; -} +}; diff --git a/apps/chrome-extension/public/_locales/en/messages.json b/apps/chrome-extension/public/_locales/en/messages.json index ed1f73200..b58d36171 100644 --- a/apps/chrome-extension/public/_locales/en/messages.json +++ b/apps/chrome-extension/public/_locales/en/messages.json @@ -214,5 +214,38 @@ }, "actionItemPlaceholder": { "message": "Write what to do after reading this page" + }, + "billing_ai_signin": { + "message": "Please check your sign-in status. Ordinary memos are still available." + }, + "billing_ai_paid": { + "message": "Summaries, automatic categories and page chat require a paid subscription." + }, + "billing_ai_limit": { + "message": "Your AI usage is limited. You can still save ordinary memos." + }, + "billing_ai_failed": { + "message": "The AI request could not complete. Your memos and existing responses are preserved." + }, + "billing_loading": { + "message": "Checking your subscription." + }, + "billing_load_failed": { + "message": "Subscription status is unavailable. This does not mean your plan changed to free." + }, + "billing_ai_usage": { + "message": "AI uses this period:" + }, + "billing_memo_limit": { + "message": "All 50 cloud memo slots are used, including trash. Existing memos remain editable and unsaved input is preserved." + }, + "billing_ai_safety": { + "message": "Up to 30 AI uses per period; a cost safety limit may restrict use earlier." + }, + "billing_view_pricing": { + "message": "View pricing on the web" + }, + "billing_refresh": { + "message": "Refresh status" } } diff --git a/apps/chrome-extension/public/_locales/ko/messages.json b/apps/chrome-extension/public/_locales/ko/messages.json index acc7d09bc..06025067e 100644 --- a/apps/chrome-extension/public/_locales/ko/messages.json +++ b/apps/chrome-extension/public/_locales/ko/messages.json @@ -214,5 +214,38 @@ }, "actionItemPlaceholder": { "message": "이 페이지를 보고 할 일을 적어보세요" + }, + "billing_ai_signin": { + "message": "로그인 상태를 확인해 주세요. 일반 메모는 계속 사용할 수 있어요." + }, + "billing_ai_paid": { + "message": "요약·자동 분류·페이지 채팅은 유료 구독에서 사용할 수 있어요." + }, + "billing_ai_limit": { + "message": "AI 이용 한도에 도달했어요. 일반 메모는 계속 저장할 수 있어요." + }, + "billing_ai_failed": { + "message": "AI 요청을 완료하지 못했어요. 메모와 기존 답변은 그대로 유지돼요." + }, + "billing_loading": { + "message": "구독 상태를 확인하고 있어요." + }, + "billing_load_failed": { + "message": "구독 상태를 확인하지 못했어요. 무료로 변경된 것은 아니에요." + }, + "billing_ai_usage": { + "message": "이번 주기 AI 사용량:" + }, + "billing_memo_limit": { + "message": "클라우드 메모 50개를 모두 사용하고 있어요. 휴지통도 포함돼요. 기존 메모는 계속 편집할 수 있고, 저장되지 않은 입력은 그대로 유지돼요." + }, + "billing_ai_safety": { + "message": "AI는 주기당 최대 30회이며 비용 안전 한도로 더 일찍 제한될 수 있어요." + }, + "billing_view_pricing": { + "message": "웹에서 요금제 보기" + }, + "billing_refresh": { + "message": "상태 새로고침" } } diff --git a/apps/web/src/app/[lng]/(no-auth)/billing/_components/BillingOverview.tsx b/apps/web/src/app/[lng]/(no-auth)/billing/_components/BillingOverview.tsx new file mode 100644 index 000000000..3fbea616c --- /dev/null +++ b/apps/web/src/app/[lng]/(no-auth)/billing/_components/BillingOverview.tsx @@ -0,0 +1,287 @@ +"use client"; + +import type { LanguageType } from "@src/modules/i18n"; +import useTranslation from "@src/modules/i18n/util.client"; +import { hasPaidSubscription } from "@web-memo/shared/hooks/billing"; +import { + Alert, + AlertDescription, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Checkbox, + Label, + Skeleton, +} from "@web-memo/ui"; +import Link from "next/link"; +import Script from "next/script"; +import { useState } from "react"; +import { useBillingActions } from "../_hooks/useBillingActions"; + +/** 결제 성공을 추정하지 않고 서버 구독 상태와 다음 행동을 표시합니다. */ +export const BillingOverview = (props: LanguageType) => { + const { t } = useTranslation(props.lng); + const billing = useBillingActions(props.lng); + const [hasBillingConsent, setHasBillingConsent] = useState(false); + const subscription = billing.subscriptionQuery.data?.subscription; + const isPaid = hasPaidSubscription(subscription ?? null); + const mutations = [ + billing.registerCardMutation, + billing.issueKeyMutation, + billing.subscribeMutation, + billing.cancelMutation, + ]; + const isBillingBusy = mutations.some((mutation) => mutation.isPending); + const billingError = mutations.find((mutation) => mutation.error)?.error; + const periodEnd = subscription?.current_period_end; + const isBillingReady = + billing.configQuery.data?.billingEnabled && + !billing.configQuery.isError && + !billing.subscriptionQuery.isError; + + return ( +
+

{t("billing.manage")}

+ {!billing.userId ? ( + + +

{t("billing.loginRequired")}

+ +
+
+ ) : ( + <> + {billing.configQuery.data?.billingEnabled && ( +