Skip to content

Commit

Permalink
Merge branch 'Weaverse:main' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
hta218 authored Dec 28, 2024
2 parents 0e6006b + 1d228bc commit bae88f3
Show file tree
Hide file tree
Showing 11 changed files with 460 additions and 117 deletions.
8 changes: 6 additions & 2 deletions app/components/cart/cart-best-sellers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
} from "@shopify/hydrogen/storefront-api-types";
import clsx from "clsx";
import { useEffect, useId, useMemo } from "react";
import type { ProductCardFragment } from "storefront-api.generated";
import { ProductCard } from "~/components/product/product-card";
import { Skeleton } from "~/components/skeleton";
import { usePrefixPathWithLocale } from "~/hooks/use-prefix-path-with-locale";
Expand Down Expand Up @@ -45,7 +46,7 @@ export function CartBestSellers({
.map(([key, val]) => (val ? `${key}=${val}` : null))
.filter(Boolean)
.join("&"),
[count, sortKey, query, reverse]
[count, sortKey, query, reverse],
);
let productsApiPath = usePrefixPathWithLocale(`/api/products?${queryString}`);

Expand Down Expand Up @@ -104,6 +105,9 @@ function CartBestSellersContent({
}

return products.map((product) => (
<ProductCard product={product} key={product.id} />
<ProductCard
product={product as unknown as ProductCardFragment}
key={product.id}
/>
));
}
112 changes: 112 additions & 0 deletions app/components/product/badges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { MoneyV2 } from "@shopify/hydrogen/storefront-api-types";
import { useThemeSettings } from "@weaverse/hydrogen";
import { clsx } from "clsx";
import { colord } from "colord";

function Badge({
text,
backgroundColor,
className,
}: {
text: string;
backgroundColor: string;
className?: string;
}) {
let { colorText, colorTextInverse, badgeBorderRadius, badgeTextTransform } =
useThemeSettings();
return (
<span
style={{
backgroundColor,
color: colord(backgroundColor).isDark() ? colorTextInverse : colorText,
borderRadius: `${badgeBorderRadius}px`,
textTransform: badgeTextTransform,
}}
className={clsx("px-1.5 py-1 uppercase text-sm", className)}
>
{text}
</span>
);
}

export function NewBadge({
publishedAt,
className,
}: { publishedAt: string; className?: string }) {
let { newBadgeText, newBadgeColor } = useThemeSettings();
if (isNewArrival(publishedAt)) {
return (
<Badge
text={newBadgeText}
backgroundColor={newBadgeColor}
className={className}
/>
);
}
return null;
}

export function BestSellerBadge({ className }: { className?: string }) {
let { bestSellerBadgeText, bestSellerBadgeColor } = useThemeSettings();
return (
<Badge
text={bestSellerBadgeText}
backgroundColor={bestSellerBadgeColor}
className={className}
/>
);
}

export function SoldOutBadge({ className }: { className?: string }) {
let { soldOutBadgeText, soldOutBadgeColor } = useThemeSettings();
return (
<Badge
text={soldOutBadgeText}
backgroundColor={soldOutBadgeColor}
className={className}
/>
);
}

export function SaleBadge({
price,
compareAtPrice,
className,
}: { price: MoneyV2; compareAtPrice: MoneyV2; className?: string }) {
let { saleBadgeContent, saleBadgeText, saleBadgeColor } = useThemeSettings();
let discount = calculateSalePercentage(price, compareAtPrice);
if (discount > 0) {
return (
<Badge
text={
saleBadgeContent === "percentage"
? `-${discount}% Off`
: saleBadgeText
}
backgroundColor={saleBadgeColor}
className={className}
/>
);
}
return null;
}

function isNewArrival(date: string, daysOld = 30) {
return (
new Date(date).valueOf() >
new Date().setDate(new Date().getDate() - daysOld).valueOf()
);
}

function calculateSalePercentage(price: MoneyV2, compareAtPrice: MoneyV2) {
if (price?.amount && compareAtPrice?.amount) {
let priceNumber = Number(price.amount);
let compareAtPriceNumber = Number(compareAtPrice.amount);
if (compareAtPriceNumber > priceNumber) {
return Math.round(
((compareAtPriceNumber - priceNumber) / compareAtPriceNumber) * 100,
);
}
}
return 0;
}
157 changes: 115 additions & 42 deletions app/components/product/product-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,78 +3,151 @@ import type {
MoneyV2,
ProductVariant,
} from "@shopify/hydrogen/storefront-api-types";
import { useThemeSettings } from "@weaverse/hydrogen";
import clsx from "clsx";
import type { ProductCardFragment } from "storefront-api.generated";
import { Link } from "~/components/link";
import { NavLink } from "~/components/nav-link";
import { ProductTag } from "~/components/product-tag";
import { VariantPrices } from "~/components/variant-prices";
import { isDiscounted, isNewArrival } from "~/utils/product";
import { QuickShopTrigger } from "./quick-shop";
import { BestSellerBadge, NewBadge, SaleBadge, SoldOutBadge } from "./badges";
import { getImageAspectRatio } from "~/utils/image";

export function ProductCard({
product,
className,
loading,
}: {
product: ProductCardFragment;
className?: string;
loading?: HTMLImageElement["loading"];
}) {
if (!product?.variants?.nodes?.length) return null;
let {
pcardAlignment,
pcardBorderRadius,
pcardBackgroundColor,
pcardShowImageOnHover,
pcardImageRatio,
pcardShowVendor,
pcardShowLowestPrice,
pcardShowSku,
pcardShowSalePrice,
pcardShowOptionSwatches,
pcardOptionToShow,
pcardMaxOptions,
pcardEnableQuickShop,
pcardQuickShopButtonType,
pcardQuickShopButtonText,
pcardQuickShopAction,
pcardQuickShopPanelType,
pcardShowSaleBadges,
pcardShowBestSellerBadges,
pcardShowNewBadges,
pcardShowOutOfStockBadges,
} = useThemeSettings();

let variants = flattenConnection(product.variants);
let firstVariant = variants[0];
let { images, badges } = product;
let [image, secondImage] = images.nodes;
let selectedVariant = variants[0];
let isBestSellerProduct = badges
.filter(Boolean)
.some(({ key, value }) => key === "best_seller" && value === "true");
if (!selectedVariant) return null;

if (!firstVariant) return null;

let { image, price, compareAtPrice } = firstVariant;
let cardLabel = "";
if (isDiscounted(price as MoneyV2, compareAtPrice as MoneyV2)) {
cardLabel = "Sale";
} else if (isNewArrival(product.publishedAt)) {
cardLabel = "New arrival";
}
let { price, compareAtPrice } = selectedVariant;

return (
<div className="flex flex-col gap-2">
<div className={clsx("grid gap-4", className)}>
<div className="relative group">
{image && (
<Link
to={`/products/${product.handle}`}
prefetch="intent"
className="block"
>
<div
style={
{
borderRadius: pcardBorderRadius,
backgroundColor: pcardBackgroundColor,
"--pcard-image-ratio": getImageAspectRatio(image, pcardImageRatio),
} as React.CSSProperties
}
>
<div className="relative group">
{image && (
<Link
to={`/products/${product.handle}`}
prefetch="intent"
className="block group relative aspect-[--pcard-image-ratio] overflow-hidden"
>
<Image
className={clsx([
"w-full h-full object-cover object-center",
"absolute inset-0",
"opacity-0 animate-fade-in",
pcardShowImageOnHover &&
secondImage &&
"transition-opacity duration-300 group-hover:opacity-0",
])}
sizes="(min-width: 64em) 25vw, (min-width: 48em) 30vw, 45vw"
data={image}
width={1000}
alt={image.altText || `Picture of ${product.title}`}
loading="lazy"
/>
{pcardShowImageOnHover && secondImage && (
<Image
className="w-full h-full opacity-0 animate-fade-in"
sizes="(min-width: 64em) 25vw, (min-width: 48em) 30vw, 45vw"
data={image}
alt={image.altText || `Picture of ${product.title}`}
loading={loading}
className={clsx([
"w-full h-full object-cover object-center",
"absolute inset-0",
"transition-opacity duration-300 opacity-0 group-hover:opacity-100",
])}
sizes="auto"
width={1000}
data={secondImage}
alt={
secondImage.altText || `Second picture of ${product.title}`
}
loading="lazy"
/>
</Link>
)}
</Link>
)}
<div className="flex gap-1 absolute top-2.5 right-2.5">
{pcardShowSaleBadges && (
<SaleBadge
price={price as MoneyV2}
compareAtPrice={compareAtPrice as MoneyV2}
/>
)}
{cardLabel && (
<ProductTag className="absolute top-2.5 right-2.5 bg-background text-body uppercase">
{cardLabel}
</ProductTag>
{pcardShowBestSellerBadges && isBestSellerProduct && (
<BestSellerBadge />
)}
<QuickShopTrigger productHandle={product.handle} />
{pcardShowNewBadges && <NewBadge publishedAt={product.publishedAt} />}
{pcardShowOutOfStockBadges && <SoldOutBadge />}
</div>
<div className="flex flex-col gap-1">
{/* <QuickShopTrigger productHandle={product.handle} /> */}
</div>
<div
className="flex flex-col py-3"
style={{ alignItems: pcardAlignment }}
>
{pcardShowVendor && (
<div className="text-sm uppercase text-body-subtle mb-2">
{product.vendor}
</div>
)}
<div className="flex items-center gap-2 mb-1">
<NavLink
to={`/products/${product.handle}`}
prefetch="intent"
className={({ isTransitioning }) => {
return isTransitioning ? "vt-product-image" : "";
}}
className={({ isTransitioning }) =>
clsx("font-bold", isTransitioning && "vt-product-image")
}
>
<span>{product.title}</span>
{firstVariant.sku && <span>({firstVariant.sku})</span>}
</NavLink>
<VariantPrices variant={firstVariant as ProductVariant} />
{pcardShowSku && selectedVariant.sku && (
<span className="text-body-subtle">({selectedVariant.sku})</span>
)}
</div>
<VariantPrices
variant={selectedVariant as ProductVariant}
showCompareAtPrice={pcardShowSalePrice}
className="mb-2"
/>
{/* {pcardShowOptionSwatches && <div>options here</div>} */}
</div>
</div>
);
Expand Down
20 changes: 18 additions & 2 deletions app/graphql/fragments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,28 @@ export const PRODUCT_CARD_FRAGMENT = `#graphql
publishedAt
handle
vendor
images(first: 2) {
nodes {
id
url
altText
width
height
}
}
badges: metafields(identifiers: [
{ namespace: "custom", key: "best_seller" }
]) {
key
namespace
value
}
priceRange {
minVariantPrice {
maxVariantPrice {
amount
currencyCode
}
maxVariantPrice {
minVariantPrice {
amount
currencyCode
}
Expand Down
9 changes: 2 additions & 7 deletions app/routes/($locale).search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { Section } from "~/components/section";
import { Swimlane } from "~/components/swimlane";
import { PRODUCT_CARD_FRAGMENT } from "~/graphql/fragments";
import { PAGINATION_SIZE } from "~/utils/const";
import { getImageLoadingPriority } from "~/utils/image";
import { seoPayload } from "~/utils/seo.server";
import {
type FeaturedData,
Expand Down Expand Up @@ -171,12 +170,8 @@ export default function Search() {
"grid grid-cols-1 lg:grid-cols-4",
])}
>
{nodes.map((product, idx) => (
<ProductCard
key={product.id}
product={product}
loading={getImageLoadingPriority(idx)}
/>
{nodes.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{hasNextPage && (
Expand Down
Loading

0 comments on commit bae88f3

Please sign in to comment.