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
11 changes: 11 additions & 0 deletions .changeset/orange-games-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@bigcommerce/catalyst-core": major
---

Add current stock message to product details page based on the store/product inventory settings.

## Migration
For existing Catalyst stores, to get the newly added feature, simply rebase the existing code with the new release code. The files to be rebased for this change to be applied are:
- core/messages/en.json
- core/app/[locale]/(default)/product/[slug]/page-data.ts
- core/app/[locale]/(default)/product/[slug]/page.tsx
28 changes: 28 additions & 0 deletions core/app/[locale]/(default)/product/[slug]/page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,10 @@ const StreamableProductQuery = graphql(
warranty
inventory {
isInStock
aggregated {
availableToSell
warningLevel
}
}
availabilityV2 {
status
Expand Down Expand Up @@ -318,3 +322,27 @@ export const getProductPricingAndRelatedProducts = cache(
return data.site.product;
},
);

const InventorySettingsQuery = graphql(`
query InventorySettingsQuery {
site {
settings {
inventory {
defaultOutOfStockMessage
showOutOfStockMessage
stockLevelDisplay
}
}
}
}
`);

export const getInventorySettingsQuery = cache(async (customerAccessToken?: string) => {
const { data } = await client.fetch({
document: InventorySettingsQuery,
customerAccessToken,
fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } },
});

return data.site.settings?.inventory;
});
43 changes: 43 additions & 0 deletions core/app/[locale]/(default)/product/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Reviews } from './_components/reviews';
import { WishlistButton } from './_components/wishlist-button';
import { WishlistButtonForm } from './_components/wishlist-button/form';
import {
getInventorySettingsQuery,
getProduct,
getProductPageMetadata,
getProductPricingAndRelatedProducts,
Expand Down Expand Up @@ -196,6 +197,47 @@ export default async function Product({ params, searchParams }: Props) {
return false;
});

const streamableStockLevelMessage = Streamable.from(async () => {
const inventorySetting = await getInventorySettingsQuery(customerAccessToken);

if (!inventorySetting) {
return null;
}

const { showOutOfStockMessage, stockLevelDisplay, defaultOutOfStockMessage } = inventorySetting;

const product = await streamableProduct;

if (!product.inventory.isInStock) {
return showOutOfStockMessage ? defaultOutOfStockMessage : null;
}

if (stockLevelDisplay === 'DONT_SHOW') {
return null;
}

const { availableToSell, warningLevel } = product.inventory.aggregated ?? {};

// availableToSell can be 0 while the product is in stock if backorderLimit is UNLIMITED
if (!availableToSell) {
return null;
}

if (stockLevelDisplay === 'SHOW_WHEN_LOW') {
if (!warningLevel) {
return null;
}

if (availableToSell && availableToSell > warningLevel) {
return null;
}
}

return t('ProductDetails.currentStock', {

Choose a reason for hiding this comment

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

What is t here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's defined on this line where translations related to "Product" page are fetched.

quantity: availableToSell,
});
});

const streameableAccordions = Streamable.from(async () => {
const product = await streamableProduct;

Expand Down Expand Up @@ -327,6 +369,7 @@ export default async function Product({ params, searchParams }: Props) {
accordions: streameableAccordions,
minQuantity: streamableMinQuantity,
maxQuantity: streamableMaxQuantity,
stockLevelMessage: streamableStockLevelMessage,
}}
quantityLabel={t('ProductDetails.quantity')}
thumbnailLabel={t('ProductDetails.thumbnail')}
Expand Down
1 change: 1 addition & 0 deletions core/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@
"decreaseNumber": "Decrease number",
"thumbnail": "View image number",
"additionalInformation": "Additional information",
"currentStock": "Current stock: {quantity, number}",
"Submit": {
"addToCart": "Add to cart",
"outOfStock": "Out of stock",
Expand Down
12 changes: 12 additions & 0 deletions core/tests/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { CustomerFixture } from './customer';
import { OrderFixture } from './order';
import { extendedPage, toHaveURL } from './page';
import { PromotionFixture } from './promotion';
import { SettingsFixture } from './settings';
import { WebPageFixture } from './webpage';

interface Fixtures {
Expand All @@ -23,6 +24,7 @@ interface Fixtures {
customer: CustomerFixture;
currency: CurrencyFixture;
promotion: PromotionFixture;
settings: SettingsFixture;
webPage: WebPageFixture;
/**
* 'reuseCustomerSession' sets the the configuration for the customer fixture and determines whether to reuse the customer session.
Expand Down Expand Up @@ -107,6 +109,16 @@ export const test = baseTest.extend<Fixtures>({
},
{ scope: 'test' },
],
settings: [
async ({ page }, use, currentTest) => {
const settingsFixture = new SettingsFixture(page, currentTest);

await use(settingsFixture);

await settingsFixture.cleanup();
},
{ scope: 'test' },
],
webPage: [
async ({ page }, use, currentTest) => {
const webPageFixture = new WebPageFixture(page, currentTest);
Expand Down
26 changes: 26 additions & 0 deletions core/tests/fixtures/settings/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Fixture } from '~/tests/fixtures/fixture';
import { InventorySettings } from '~/tests/fixtures/utils/api/settings';

export class SettingsFixture extends Fixture {
private initialInventorySettings: InventorySettings | null = null;

async getInventorySettings(): Promise<InventorySettings> {
const settings = await this.api.settings.getInventorySettings();

return settings;
}

async setInventorySettings(settings: InventorySettings): Promise<void> {
if (!this.initialInventorySettings) {
this.initialInventorySettings = await this.getInventorySettings();
}

await this.api.settings.setInventorySettings(settings);
}

async cleanup() {
if (this.initialInventorySettings) {
await this.api.settings.setInventorySettings(this.initialInventorySettings);
}
}
}
3 changes: 3 additions & 0 deletions core/tests/fixtures/utils/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CurrenciesApi, currenciesHttpClient } from '~/tests/fixtures/utils/api/
import { CustomersApi, customersHttpClient } from '~/tests/fixtures/utils/api/customers';
import { OrdersApi, ordersHttpClient } from '~/tests/fixtures/utils/api/orders';
import { PromotionsApi, promotionsHttpClient } from '~/tests/fixtures/utils/api/promotions';
import { SettingsApi, settingsHttpClient } from '~/tests/fixtures/utils/api/settings';
import { WebPagesApi, webPagesHttpClient } from '~/tests/fixtures/utils/api/webpages';

export interface ApiClient {
Expand All @@ -13,6 +14,7 @@ export interface ApiClient {
currencies: CurrenciesApi;
orders: OrdersApi;
promotions: PromotionsApi;
settings: SettingsApi;
webPages: WebPagesApi;
}

Expand All @@ -23,5 +25,6 @@ export const httpApiClient: ApiClient = {
currencies: currenciesHttpClient,
orders: ordersHttpClient,
promotions: promotionsHttpClient,
settings: settingsHttpClient,
webPages: webPagesHttpClient,
};
39 changes: 39 additions & 0 deletions core/tests/fixtures/utils/api/settings/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { z } from 'zod';

import { httpClient } from '../client';
import { apiResponseSchema } from '../schema';

import { InventorySettings, SettingsApi } from '.';

const InventorySettingsSchema = z
.object({
default_out_of_stock_message: z.string(),
show_out_of_stock_message: z.boolean(),
stock_level_display: z.enum(['dont_show', 'show', 'show_when_low']).nullable(),
})
.transform(
(data): InventorySettings => ({
defaultOutOfStockMessage: data.default_out_of_stock_message,
showOutOfStockMessage: data.show_out_of_stock_message,
stockLevelDisplay: data.stock_level_display,
}),
);

const transformInventorySettingsData = (data: InventorySettings) => ({
default_out_of_stock_message: data.defaultOutOfStockMessage,
show_out_of_stock_message: data.showOutOfStockMessage,
stock_level_display: data.stockLevelDisplay,
});

export const settingsHttpClient: SettingsApi = {
getInventorySettings: async (): Promise<InventorySettings> => {
const resp = await httpClient
.get(`/v3/settings/inventory`)
.parse(apiResponseSchema(InventorySettingsSchema));

return resp.data;
},
setInventorySettings: async (settings: InventorySettings): Promise<void> => {
await httpClient.put(`/v3/settings/inventory`, transformInventorySettingsData(settings));
},
};
12 changes: 12 additions & 0 deletions core/tests/fixtures/utils/api/settings/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export interface InventorySettings {
readonly defaultOutOfStockMessage?: string;
readonly showOutOfStockMessage?: boolean;
readonly stockLevelDisplay?: 'dont_show' | 'show' | 'show_when_low' | null;
}

export interface SettingsApi {
getInventorySettings: () => Promise<InventorySettings>;
setInventorySettings: (settings: InventorySettings) => Promise<void>;
}

export { settingsHttpClient } from './http';
49 changes: 49 additions & 0 deletions core/tests/ui/e2e/product.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ test('Displays a simple product and can add it to the cart', async ({

test('Displays out of stock product correctly', async ({ page, catalog }) => {
const t = await getTranslations('Product.ProductDetails');

const product = await catalog.createSimpleProduct({
inventoryTracking: 'product',
inventoryLevel: 0,
Expand All @@ -46,6 +47,54 @@ test('Displays out of stock product correctly', async ({ page, catalog }) => {
await expect(page.getByRole('button', { name: t('Submit.outOfStock') })).toBeVisible();
});

test('Displays out of stock product correctly when out of stock message is enabled', async ({
page,
catalog,
settings,
}) => {
const t = await getTranslations('Product.ProductDetails');

await settings.setInventorySettings({
showOutOfStockMessage: true,
defaultOutOfStockMessage: 'Currently out of stock',
});

const product = await catalog.createSimpleProduct({
inventoryTracking: 'product',
inventoryLevel: 0,
});

await page.goto(product.path);
await page.waitForLoadState('networkidle');

await expect(page.getByRole('heading', { name: product.name })).toBeVisible();
await expect(page.getByRole('button', { name: t('Submit.outOfStock') })).toBeVisible();
await expect(page.getByText('Currently out of stock')).toBeVisible();
});

test('Displays current stock message when stock level message is enabled', async ({
page,
catalog,
settings,
}) => {
const t = await getTranslations('Product.ProductDetails');

await settings.setInventorySettings({
stockLevelDisplay: 'show',
});

const product = await catalog.createSimpleProduct({
inventoryTracking: 'product',
inventoryLevel: 10,
});

await page.goto(product.path);
await page.waitForLoadState('networkidle');

await expect(page.getByRole('heading', { name: product.name })).toBeVisible();
await expect(page.getByText(t('currentStock', { quantity: 10 }))).toBeVisible();
});

test('Displays product price correctly for an alternate currency', async ({
page,
catalog,
Expand Down
16 changes: 16 additions & 0 deletions core/vibes/soul/sections/product-detail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface ProductDetailProduct {
>;
minQuantity?: Streamable<number | null>;
maxQuantity?: Streamable<number | null>;
stockLevelMessage?: Streamable<string | null>;
}

export interface ProductDetailProps<F extends Field> {
Expand Down Expand Up @@ -119,6 +120,17 @@ export function ProductDetail<F extends Field>({
)}
</Stream>
</div>
<div className="group/product-stock-level mb-8 sm:mb-2 md:mb-0">
<Stream fallback={<ProductStockSkeleton />} value={product.stockLevelMessage}>
{(stockLevelMessage) =>
Boolean(stockLevelMessage) && (
<p className="text-sm text-[var(--product-detail-secondary-text,hsl(var(--contrast-500)))]">
{stockLevelMessage}
</p>
)
}
</Stream>
</div>
<div className="group/product-gallery mb-8 @2xl:hidden">
<Stream fallback={<ProductGallerySkeleton />} value={product.images}>
{(images) => (
Expand Down Expand Up @@ -232,6 +244,10 @@ function PriceLabelSkeleton() {
return <Skeleton.Box className="my-5 h-4 w-20 rounded-md" />;
}

function ProductStockSkeleton() {
return <Skeleton.Box className="my-3 h-2 w-20 rounded-md" />;
}

function RatingSkeleton() {
return (
<Skeleton.Root
Expand Down
Loading