Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 6 additions & 1 deletion schema/schema.graphql
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 생성 파일 — 수정하지 않는다. caquick-be local@7bece88a93b583c4c2d27ba42438cea7f01c9a0d 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다.
# 생성 파일 — 수정하지 않는다. caquick-be local@251a957ea22bb84be535aa3d6cb8a4214e5e585b 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다.
# 갱신: pnpm schema:pull [ref]

# ---- src/features/audit-log/audit-log.types.graphql ----
Expand Down Expand Up @@ -3145,6 +3145,11 @@ type AdminBanner {
"""
linkCategoryId: ID
"""
링크 대상이 지금 노출 가능한지. 구매자 앱과 같은 기준(상품·매장·카테고리가 활성·미삭제, 상품은 소속 매장까지)이며,
false면 구매자 앱이 이 배너를 건너뛰고 다음 배너를 노출한다. linkType이 NONE·URL이면 항상 true.
"""
linkTargetAvailable: Boolean!
"""
노출 시작 일시. null이면 시작 제한 없음.
"""
startsAt: DateTime
Expand Down
2 changes: 2 additions & 0 deletions src/features/banners/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const AdminBannersDocument = graphql(/* GraphQL */ `
linkProductId
linkStoreId
linkCategoryId
linkTargetAvailable
startsAt
endsAt
sortOrder
Expand Down Expand Up @@ -101,6 +102,7 @@ const AdminBannersVisibleDocument = graphql(/* GraphQL */ `
id
placement
linkCategoryId
linkTargetAvailable
startsAt
endsAt
sortOrder
Expand Down
16 changes: 15 additions & 1 deletion src/features/banners/live-status.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ const BEFORE = '2026-09-30T23:59:59.999Z';
const AFTER = '2026-10-01T00:00:00.001Z';

describe('배너 현재 노출', () => {
const live = { isActive: true, startsAt: null, endsAt: null, linkCategoryId: null };
const live = {
isActive: true,
startsAt: null,
endsAt: null,
linkCategoryId: null,
linkTargetAvailable: true,
};
it.each([
{
name: '슬롯마다 정렬 순서가 가장 작은 것',
Expand Down Expand Up @@ -34,6 +40,14 @@ describe('배너 현재 노출', () => {
],
want: ['4'],
},
{
name: '링크 대상이 숨김이면 구매자 앱처럼 건너뛰고 다음 배너',
banners: [
{ ...live, id: '1', placement: 'HOME_MAIN', sortOrder: 0, linkTargetAvailable: false },
{ ...live, id: '2', placement: 'HOME_MAIN', sortOrder: 1 },
],
want: ['2'],
},
{
name: '카테고리 배치는 카테고리마다 따로 고른다',
banners: [
Expand Down
5 changes: 3 additions & 2 deletions src/features/banners/live-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface SlotBanner extends Timed {
id: string;
placement: BannerPlacement;
linkCategoryId?: string | null;
linkTargetAvailable: boolean;
sortOrder: number;
}

Expand All @@ -26,13 +27,13 @@ const byId = (a: string, b: string) =>

/**
* 슬롯마다 지금 구매자에게 보이는 배너 1개의 ID. 노출 중인 것 가운데 정렬 순서가 가장 작고, 같으면 먼저 등록한(ID가 작은) 것.
* 연결한 상품·매장·카테고리가 숨김이면 구매자 앱은 다음 배너로 넘어가는데, 이 목록은 그 상태를 모른다.
* 연결한 상품·매장·카테고리가 숨김이면(linkTargetAvailable=false) 구매자 앱처럼 건너뛰고 다음 배너를 고른다.
*/
export function currentBannerIds(banners: readonly SlotBanner[], now: Date): Set<string> {
const winners = new Map<string, SlotBanner>();
for (const b of banners) {
const key = slotKey(b);
if (key === null || liveStatus(b, now) !== 'LIVE') continue;
if (key === null || !b.linkTargetAvailable || liveStatus(b, now) !== 'LIVE') continue;
const cur = winners.get(key);
if (
!cur ||
Expand Down
5 changes: 4 additions & 1 deletion src/features/banners/pages/banners-list-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ export function BannersListPage({ search, onSearchChange }: Props) {
<span className="flex items-center gap-1.5">
<StatusPill tone={s.tone}>{s.label}</StatusPill>
{current.has(row.original.id) && <StatusPill tone="primary">현재 노출</StatusPill>}
{!row.original.linkTargetAvailable && (
<StatusPill tone="caution">링크 대상 숨김</StatusPill>
)}
</span>
);
},
Expand Down Expand Up @@ -142,7 +145,7 @@ export function BannersListPage({ search, onSearchChange }: Props) {
<>
<PageHeader
title="배너"
description="'현재 노출'은 자리마다 구매자에게 지금 보이는 배너 1개입니다. 연결한 상품·매장·카테고리가 숨김이면 구매자 앱에는 다음 배너가 보입니다."
description="'현재 노출'은 자리마다 구매자에게 지금 보이는 배너 1개입니다. 연결한 상품·매장·카테고리가 숨김인 배너는 '링크 대상 숨김'으로 표시되며 구매자 앱에서 건너뜁니다."
meta={list.data ? `전체 ${formatCount(list.data.totalCount)}개` : undefined}
actions={
<Button asChild>
Expand Down
30 changes: 30 additions & 0 deletions src/features/banners/pages/banners.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const banner = {
linkProductId: null,
linkStoreId: null,
linkCategoryId: null,
linkTargetAvailable: true,
startsAt: null,
endsAt: null,
sortOrder: 0,
Expand Down Expand Up @@ -117,6 +118,35 @@ describe('배너', () => {
await vi.waitFor(() => expect(deleted).toEqual({ bannerId: '5' }));
});

it('목록: 링크 대상이 숨김인 배너는 현재 노출에서 건너뛰고 링크 대상 숨김으로 표시한다', async () => {
const rows = [
{
...banner,
id: '5',
title: '대상 숨김',
linkType: 'PRODUCT',
linkProductId: '3',
linkTargetAvailable: false,
},
{ ...banner, id: '6', title: '다음 배너', sortOrder: 1 },
];
server.use(
gqlOk('AdminBanners', {
adminBanners: { items: rows, totalCount: 2, hasMore: false, nextCursor: null },
}),
gqlOk('AdminBannersVisible', {
adminBanners: { items: rows, hasMore: false, nextCursor: null },
}),
);
boot('/banners');
const statusOf = async (title: string) =>
within((await screen.findByRole('link', { name: title })).closest('tr')!).getAllByRole(
'cell',
)[2]!.textContent;
await vi.waitFor(async () => expect(await statusOf('다음 배너')).toBe('노출 중현재 노출'));
expect(await statusOf('대상 숨김')).toBe('노출 중링크 대상 숨김');
});

it('목록을 켜 둔 채 시작 시각이 되면 예약이 노출 중·현재 노출로 바뀐다', async () => {
// 실제 시간도 흐르게 둬 부팅·요청은 그대로 진행되고, 시작 시각만 앞당겨 넘긴다
vi.useFakeTimers({ shouldAdvanceTime: true });
Expand Down
12 changes: 6 additions & 6 deletions src/graphql/generated/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ type Documents = {
"\n query AdminAuditLogs($input: AdminAuditLogListInput) {\n adminAuditLogs(input: $input) {\n items {\n id\n actorAccountId\n actorAccountType\n actorLabel\n storeId\n targetType\n targetId\n action\n beforeJson\n afterJson\n ipAddress\n userAgent\n createdAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n": typeof types.AdminAuditLogsDocument,
"\n query AdminAuditStorePicker($input: AdminStoreListInput) {\n adminStores(input: $input) {\n items {\n id\n storeName\n isActive\n }\n }\n }\n": typeof types.AdminAuditStorePickerDocument,
"\n query AdminAuditActorPicker($sellers: AdminSellerListInput, $admins: CursorInput) {\n adminSellers(input: $sellers) {\n items {\n accountId\n username\n name\n }\n }\n adminAdmins(input: $admins) {\n items {\n accountId\n username\n name\n }\n }\n }\n": typeof types.AdminAuditActorPickerDocument,
"\n query AdminBanners($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n": typeof types.AdminBannersDocument,
"\n query AdminBanners($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n linkTargetAvailable\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n": typeof types.AdminBannersDocument,
"\n query AdminBanner($bannerId: ID!) {\n adminBanner(bannerId: $bannerId) {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n }\n": typeof types.AdminBannerDocument,
"\n mutation AdminCreateBanner($input: AdminCreateBannerInput!) {\n adminCreateBanner(input: $input) {\n id\n }\n }\n": typeof types.AdminCreateBannerDocument,
"\n mutation AdminUpdateBanner($input: AdminUpdateBannerInput!) {\n adminUpdateBanner(input: $input) {\n id\n updatedAt\n }\n }\n": typeof types.AdminUpdateBannerDocument,
"\n mutation AdminDeleteBanner($bannerId: ID!) {\n adminDeleteBanner(bannerId: $bannerId)\n }\n": typeof types.AdminDeleteBannerDocument,
"\n query AdminBannersVisible($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n }\n hasMore\n nextCursor\n }\n }\n": typeof types.AdminBannersVisibleDocument,
"\n query AdminBannersVisible($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n linkCategoryId\n linkTargetAvailable\n startsAt\n endsAt\n sortOrder\n isActive\n }\n hasMore\n nextCursor\n }\n }\n": typeof types.AdminBannersVisibleDocument,
"\n query AdminBannerProductOptions($input: AdminProductListInput) {\n adminProducts(input: $input) {\n items {\n id\n name\n storeName\n storeIsActive\n }\n }\n }\n": typeof types.AdminBannerProductOptionsDocument,
"\n query AdminBannerStoreOptions($input: AdminStoreListInput) {\n adminStores(input: $input) {\n items {\n id\n storeName\n }\n }\n }\n": typeof types.AdminBannerStoreOptionsDocument,
"\n query AdminBannerCategoryOptions($input: AdminCategoryListInput) {\n adminCategories(input: $input) {\n id\n name\n isActive\n }\n }\n": typeof types.AdminBannerCategoryOptionsDocument,
Expand Down Expand Up @@ -104,12 +104,12 @@ const documents: Documents = {
"\n query AdminAuditLogs($input: AdminAuditLogListInput) {\n adminAuditLogs(input: $input) {\n items {\n id\n actorAccountId\n actorAccountType\n actorLabel\n storeId\n targetType\n targetId\n action\n beforeJson\n afterJson\n ipAddress\n userAgent\n createdAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n": types.AdminAuditLogsDocument,
"\n query AdminAuditStorePicker($input: AdminStoreListInput) {\n adminStores(input: $input) {\n items {\n id\n storeName\n isActive\n }\n }\n }\n": types.AdminAuditStorePickerDocument,
"\n query AdminAuditActorPicker($sellers: AdminSellerListInput, $admins: CursorInput) {\n adminSellers(input: $sellers) {\n items {\n accountId\n username\n name\n }\n }\n adminAdmins(input: $admins) {\n items {\n accountId\n username\n name\n }\n }\n }\n": types.AdminAuditActorPickerDocument,
"\n query AdminBanners($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n": types.AdminBannersDocument,
"\n query AdminBanners($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n linkTargetAvailable\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n": types.AdminBannersDocument,
"\n query AdminBanner($bannerId: ID!) {\n adminBanner(bannerId: $bannerId) {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n }\n": types.AdminBannerDocument,
"\n mutation AdminCreateBanner($input: AdminCreateBannerInput!) {\n adminCreateBanner(input: $input) {\n id\n }\n }\n": types.AdminCreateBannerDocument,
"\n mutation AdminUpdateBanner($input: AdminUpdateBannerInput!) {\n adminUpdateBanner(input: $input) {\n id\n updatedAt\n }\n }\n": types.AdminUpdateBannerDocument,
"\n mutation AdminDeleteBanner($bannerId: ID!) {\n adminDeleteBanner(bannerId: $bannerId)\n }\n": types.AdminDeleteBannerDocument,
"\n query AdminBannersVisible($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n }\n hasMore\n nextCursor\n }\n }\n": types.AdminBannersVisibleDocument,
"\n query AdminBannersVisible($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n linkCategoryId\n linkTargetAvailable\n startsAt\n endsAt\n sortOrder\n isActive\n }\n hasMore\n nextCursor\n }\n }\n": types.AdminBannersVisibleDocument,
"\n query AdminBannerProductOptions($input: AdminProductListInput) {\n adminProducts(input: $input) {\n items {\n id\n name\n storeName\n storeIsActive\n }\n }\n }\n": types.AdminBannerProductOptionsDocument,
"\n query AdminBannerStoreOptions($input: AdminStoreListInput) {\n adminStores(input: $input) {\n items {\n id\n storeName\n }\n }\n }\n": types.AdminBannerStoreOptionsDocument,
"\n query AdminBannerCategoryOptions($input: AdminCategoryListInput) {\n adminCategories(input: $input) {\n id\n name\n isActive\n }\n }\n": types.AdminBannerCategoryOptionsDocument,
Expand Down Expand Up @@ -218,7 +218,7 @@ export function graphql(source: "\n query AdminAuditActorPicker($sellers: Admin
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query AdminBanners($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n"): typeof import('./graphql').AdminBannersDocument;
export function graphql(source: "\n query AdminBanners($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n linkTargetAvailable\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n"): typeof import('./graphql').AdminBannersDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
Expand All @@ -238,7 +238,7 @@ export function graphql(source: "\n mutation AdminDeleteBanner($bannerId: ID!)
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query AdminBannersVisible($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n }\n hasMore\n nextCursor\n }\n }\n"): typeof import('./graphql').AdminBannersVisibleDocument;
export function graphql(source: "\n query AdminBannersVisible($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n linkCategoryId\n linkTargetAvailable\n startsAt\n endsAt\n sortOrder\n isActive\n }\n hasMore\n nextCursor\n }\n }\n"): typeof import('./graphql').AdminBannersVisibleDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
Expand Down
6 changes: 4 additions & 2 deletions src/graphql/generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,7 @@ export type AdminBannersQueryVariables = Exact<{
}>;


export type AdminBannersQuery = { adminBanners: { totalCount: number, hasMore: boolean, nextCursor: string | null, items: Array<{ id: string, placement: BannerPlacement, title: string | null, imageUrl: string, linkType: BannerLinkType, linkUrl: string | null, linkProductId: string | null, linkStoreId: string | null, linkCategoryId: string | null, startsAt: string | null, endsAt: string | null, sortOrder: number, isActive: boolean, createdAt: string, updatedAt: string }> } };
export type AdminBannersQuery = { adminBanners: { totalCount: number, hasMore: boolean, nextCursor: string | null, items: Array<{ id: string, placement: BannerPlacement, title: string | null, imageUrl: string, linkType: BannerLinkType, linkUrl: string | null, linkProductId: string | null, linkStoreId: string | null, linkCategoryId: string | null, linkTargetAvailable: boolean, startsAt: string | null, endsAt: string | null, sortOrder: number, isActive: boolean, createdAt: string, updatedAt: string }> } };

export type AdminBannerQueryVariables = Exact<{
bannerId: string | number;
Expand Down Expand Up @@ -908,7 +908,7 @@ export type AdminBannersVisibleQueryVariables = Exact<{
}>;


export type AdminBannersVisibleQuery = { adminBanners: { hasMore: boolean, nextCursor: string | null, items: Array<{ id: string, placement: BannerPlacement, linkCategoryId: string | null, startsAt: string | null, endsAt: string | null, sortOrder: number, isActive: boolean }> } };
export type AdminBannersVisibleQuery = { adminBanners: { hasMore: boolean, nextCursor: string | null, items: Array<{ id: string, placement: BannerPlacement, linkCategoryId: string | null, linkTargetAvailable: boolean, startsAt: string | null, endsAt: string | null, sortOrder: number, isActive: boolean }> } };

export type AdminBannerProductOptionsQueryVariables = Exact<{
input?: AdminProductListInput | null | undefined;
Expand Down Expand Up @@ -1507,6 +1507,7 @@ export const AdminBannersDocument = new TypedDocumentString(`
linkProductId
linkStoreId
linkCategoryId
linkTargetAvailable
startsAt
endsAt
sortOrder
Expand Down Expand Up @@ -1568,6 +1569,7 @@ export const AdminBannersVisibleDocument = new TypedDocumentString(`
id
placement
linkCategoryId
linkTargetAvailable
startsAt
endsAt
sortOrder
Expand Down
Loading