Fix nested git repository and track all inner files

This commit is contained in:
Ravi Gulhane 2026-06-03 13:35:55 +05:30
parent 98f803ffe4
commit 685f3bff81
244 changed files with 48404 additions and 1 deletions

@ -1 +0,0 @@
Subproject commit a775bf37795d7eb10aab04e655ace174afa23749

View File

@ -0,0 +1,16 @@
---
description: Primary page title typography (Cormorant, same as homepage Testimonials)
globs: Hydrogen/web/**/*.tsx
alwaysApply: false
---
# ABiiR page titles (Hydrogen)
Use the **same font size and face as the homepage `Testimonials` section `h2`** for all primary page and section titles.
- **Import** from `~/lib/pageTitle`:
- `PAGE_TITLE_TYPOGRAPHY_CLASS` — Cormorant Garamond light, `clamp(2.5rem, 5.5vw, 64.6px)`, `leading-[1.12]`, `tracking-[-0.969px]` (matches `Testimonials.tsx`).
- `PAGE_TITLE_DEFAULT_TEXT_CLASS` — optional default charcoal `text-[#1a1a18]` when on cream backgrounds.
- **Or** use `<PageTitle>` from `~/components/PageTitle.tsx` for full pages (default `h1`, charcoal text). Add `className` for alignment (e.g. `text-center`) or override color with `className` / `style` for heroes and CMS-driven colors.
Do not one-off duplicate these sizes; extend the shared tokens if the design changes.

View File

@ -0,0 +1,52 @@
---
description:
globs:
alwaysApply: true
---
# React Router Import Rule for Hydrogen
## Overview
This Hydrogen project is based on React Router, not Remix. When working with documentation or code examples, you should always use imports from the appropriate React Router packages instead of Remix packages.
## Import Replacements
When you see imports from Remix packages, replace them with their equivalent React Router v7 packages. Here are the common replacements:
| Remix v2 Package | React Router v7 Package |
|------------------|-------------------------|
| `@remix-run/react` | `react-router` |
| `@remix-run/dev` | `@react-router/dev` |
| `@remix-run/architect` | `@react-router/architect` |
| `@remix-run/cloudflare` | `@react-router/cloudflare` |
| `@remix-run/express` | `@react-router/express` |
| `@remix-run/fs-routes` | `@react-router/fs-routes` |
| `@remix-run/node` | `@react-router/node` |
| `@remix-run/route-config` | `@react-router/dev` |
| `@remix-run/routes-option-adapter` | `@react-router/remix-routes-option-adapter` |
| `@remix-run/serve` | `@react-router/serve` |
| `@remix-run/server-runtime` | `react-router` |
| `@remix-run/testing` | `react-router` |
NEVER USE 'react-router-dom' imports!
## Common Import Examples
```js
// INCORRECT (Remix style)
import { useLoaderData, Link, Form, useActionData, useNavigation, useSubmit } from '@remix-run/react';
// CORRECT (React Router style)
import { useLoaderData, Link, Form, useActionData, useNavigation, useSubmit } from 'react-router';
```
## Development Guidelines
1. Always check existing code in the project to understand which specific React Router hooks and components are being used
2. When generating new code or modifying existing code, ensure all routing-related imports come from the correct React Router packages
3. If following documentation or examples based on Remix, adapt the code to use React Router equivalents
When working in this codebase, always follow the React Router patterns that are already established in the existing code.
For more information, consult the official Remix to React Router upgrade guide: https://reactrouter.com/upgrading/remix

10
Hydrogen/web/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
node_modules
/.cache
/build
/dist
/public/build
/.mf
.env
.shopify
.react-router
scripts/pdp-seed.json

View File

@ -0,0 +1,29 @@
import type {IGraphQLConfig} from 'graphql-config';
import {getSchema} from '@shopify/hydrogen-codegen';
/**
* GraphQL Config
* @see https://the-guild.dev/graphql/config/docs/user/usage
* @type {IGraphQLConfig}
*/
const graphqlConfig: IGraphQLConfig = {
projects: {
default: {
schema: getSchema('storefront'),
documents: [
'./*.{ts,tsx,js,jsx}',
'./app/**/*.{ts,tsx,js,jsx}',
'!./app/graphql/**/*.{ts,tsx,js,jsx}',
],
},
customer: {
schema: getSchema('customer-account'),
documents: ['./app/graphql/customer-account/*.{ts,tsx,js,jsx}'],
},
// Add your own GraphQL projects here for CMS, Shopify Admin API, etc.
},
};
export default graphqlConfig;

1
Hydrogen/web/.npmrc Normal file
View File

@ -0,0 +1 @@
legacy-peer-deps=true

2780
Hydrogen/web/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

45
Hydrogen/web/README.md Normal file
View File

@ -0,0 +1,45 @@
# Hydrogen template: Skeleton
Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen.
[Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen)
[Get familiar with Remix](https://remix.run/docs/en/v1)
## What's included
- Remix
- Hydrogen
- Oxygen
- Vite
- Shopify CLI
- ESLint
- Prettier
- GraphQL generator
- TypeScript and JavaScript flavors
- Minimal setup of components and routes
## Getting started
**Requirements:**
- Node.js version 18.0.0 or higher
```bash
npm create @shopify/hydrogen@latest
```
## Building for production
```bash
npm run build
```
## Local development
```bash
npm run dev
```
## Setup for using Customer Account API (`/account` section)
Follow step 1 and 2 of <https://shopify.dev/docs/custom-storefronts/building-with-the-customer-account-api/hydrogen#step-1-set-up-a-public-domain-for-local-development>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

View File

@ -0,0 +1,6 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon">
<path id="Vector" d="M21.3333 4C20.6261 4 19.9478 4.28095 19.4477 4.78105C18.9476 5.28115 18.6667 5.95942 18.6667 6.66667V14.6667C18.6667 15.3739 18.9476 16.0522 19.4477 16.5523C19.9478 17.0524 20.6261 17.3333 21.3333 17.3333C21.687 17.3333 22.0261 17.4738 22.2761 17.7239C22.5262 17.9739 22.6667 18.313 22.6667 18.6667V20C22.6667 20.7072 22.3857 21.3855 21.8856 21.8856C21.3855 22.3857 20.7072 22.6667 20 22.6667C19.6464 22.6667 19.3072 22.8071 19.0572 23.0572C18.8071 23.3072 18.6667 23.6464 18.6667 24V26.6667C18.6667 27.0203 18.8071 27.3594 19.0572 27.6095C19.3072 27.8595 19.6464 28 20 28C22.1217 28 24.1566 27.1571 25.6569 25.6569C27.1571 24.1566 28 22.1217 28 20V6.66667C28 5.95942 27.719 5.28115 27.219 4.78105C26.7189 4.28095 26.0406 4 25.3333 4H21.3333Z" stroke="var(--stroke-0, #B39763)" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M6.66667 4C5.95942 4 5.28115 4.28095 4.78105 4.78105C4.28095 5.28115 4 5.95942 4 6.66667V14.6667C4 15.3739 4.28095 16.0522 4.78105 16.5523C5.28115 17.0524 5.95942 17.3333 6.66667 17.3333C7.02029 17.3333 7.35943 17.4738 7.60948 17.7239C7.85952 17.9739 8 18.313 8 18.6667V20C8 20.7072 7.71905 21.3855 7.21895 21.8856C6.71885 22.3857 6.04058 22.6667 5.33333 22.6667C4.97971 22.6667 4.64057 22.8071 4.39052 23.0572C4.14048 23.3072 4 23.6464 4 24V26.6667C4 27.0203 4.14048 27.3594 4.39052 27.6095C4.64057 27.8595 4.97971 28 5.33333 28C7.45506 28 9.4899 27.1571 10.9902 25.6569C12.4905 24.1566 13.3333 22.1217 13.3333 20V6.66667C13.3333 5.95942 13.0524 5.28115 12.5523 4.78105C12.0522 4.28095 11.3739 4 10.6667 4H6.66667Z" stroke="var(--stroke-0, #B39763)" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 KiB

View File

@ -0,0 +1,7 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon">
<path id="Vector" d="M11.6667 1.66667V6.66667C11.6665 6.94624 11.7368 7.22134 11.8708 7.46667L16.4625 15.8667C16.6014 16.1205 16.6718 16.4061 16.6668 16.6954C16.6618 16.9848 16.5816 17.2678 16.434 17.5167C16.2864 17.7656 16.0766 17.9717 15.8252 18.1149C15.5737 18.2581 15.2894 18.3334 15 18.3333H5C4.71065 18.3334 4.42626 18.2581 4.17483 18.1149C3.92339 17.9717 3.71356 17.7656 3.56599 17.5167C3.41842 17.2678 3.33819 16.9848 3.3332 16.6954C3.3282 16.4061 3.39861 16.1205 3.5375 15.8667L8.12917 7.46667C8.26324 7.22134 8.33345 6.94624 8.33333 6.66667V1.66667" stroke="var(--stroke-0, #5D0B24)" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M5.3775 12.5H14.6225" stroke="var(--stroke-0, #5D0B24)" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M7.08333 1.66667H12.9167" stroke="var(--stroke-0, #5D0B24)" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,6 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon">
<path id="Vector" d="M9.16667 16.6667C7.70338 16.6711 6.29188 16.1254 5.2121 15.1378C4.13232 14.1503 3.46314 12.793 3.33728 11.3351C3.21142 9.87722 3.63808 8.4253 4.53262 7.26728C5.42717 6.10927 6.72427 5.32975 8.16667 5.08333C12.9167 4.16667 14.1667 3.73333 15.8333 1.66667C16.6667 3.33333 17.5 5.15 17.5 8.33333C17.5 12.9167 13.5167 16.6667 9.16667 16.6667Z" stroke="var(--stroke-0, #5D0B24)" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M1.66667 17.5C1.66667 15 3.20833 13.0333 5.9 12.5C7.91667 12.1 10 10.8333 10.8333 10" stroke="var(--stroke-0, #5D0B24)" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 832 B

View File

@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon">
<path id="Vector" d="M16.6667 10.8333C16.6667 15 13.75 17.0833 10.2833 18.2917C10.1018 18.3532 9.90461 18.3502 9.725 18.2833C6.25 17.0833 3.33333 15 3.33333 10.8333V5C3.33333 4.77899 3.42113 4.56703 3.57741 4.41074C3.73369 4.25446 3.94565 4.16667 4.16667 4.16667C5.83333 4.16667 7.91667 3.16667 9.36667 1.9C9.54321 1.74917 9.76779 1.66629 10 1.66629C10.2322 1.66629 10.4568 1.74917 10.6333 1.9C12.0917 3.175 14.1667 4.16667 15.8333 4.16667C16.0543 4.16667 16.2663 4.25446 16.4226 4.41074C16.5789 4.56703 16.6667 4.77899 16.6667 5V10.8333Z" stroke="var(--stroke-0, #5D0B24)" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 819 B

View File

@ -0,0 +1,9 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon">
<path id="Vector" d="M8.28083 12.9167C8.20643 12.6283 8.05612 12.3651 7.84551 12.1545C7.63491 11.9439 7.37173 11.7936 7.08333 11.7192L1.97083 10.4008C1.88361 10.3761 1.80684 10.3235 1.75218 10.2512C1.69751 10.1789 1.66794 10.0907 1.66794 10C1.66794 9.90933 1.69751 9.82113 1.75218 9.7488C1.80684 9.67646 1.88361 9.62392 1.97083 9.59917L7.08333 8.28C7.37162 8.20567 7.63474 8.05548 7.84533 7.84503C8.05593 7.63459 8.2063 7.37157 8.28083 7.08333L9.59917 1.97083C9.62367 1.88327 9.67615 1.80612 9.7486 1.75116C9.82105 1.69621 9.90948 1.66646 10.0004 1.66646C10.0913 1.66646 10.1798 1.69621 10.2522 1.75116C10.3247 1.80612 10.3772 1.88327 10.4017 1.97083L11.7192 7.08333C11.7936 7.37173 11.9439 7.63491 12.1545 7.84551C12.3651 8.05612 12.6283 8.20643 12.9167 8.28083L18.0292 9.59833C18.1171 9.62258 18.1946 9.67501 18.2499 9.74756C18.3051 9.82012 18.335 9.9088 18.335 10C18.335 10.0912 18.3051 10.1799 18.2499 10.2524C18.1946 10.325 18.1171 10.3774 18.0292 10.4017L12.9167 11.7192C12.6283 11.7936 12.3651 11.9439 12.1545 12.1545C11.9439 12.3651 11.7936 12.6283 11.7192 12.9167L10.4008 18.0292C10.3763 18.1167 10.3238 18.1939 10.2514 18.2488C10.179 18.3038 10.0905 18.3335 9.99958 18.3335C9.90865 18.3335 9.82021 18.3038 9.74777 18.2488C9.67532 18.1939 9.62284 18.1167 9.59833 18.0292L8.28083 12.9167Z" stroke="var(--stroke-0, #5D0B24)" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M16.6667 2.5V5.83333" stroke="var(--stroke-0, #5D0B24)" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M18.3333 4.16667H15" stroke="var(--stroke-0, #5D0B24)" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_4" d="M3.33333 14.1667V15.8333" stroke="var(--stroke-0, #5D0B24)" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_5" d="M4.16667 15H2.5" stroke="var(--stroke-0, #5D0B24)" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none">
<style>
.stroke {
stroke: #000;
}
.fill {
fill: #000;
}
@media (prefers-color-scheme: dark) {
.stroke {
stroke: #fff;
}
.fill {
fill: #fff;
}
}
</style>
<path
class="stroke"
fill-rule="evenodd"
d="M16.1 16.04 1 8.02 6.16 5.3l5.82 3.09 4.88-2.57-5.82-3.1L16.21 0l15.1 8.02-5.17 2.72-5.5-2.91-4.88 2.57 5.5 2.92-5.16 2.72Z"
/>
<path
class="fill"
fill-rule="evenodd"
d="M16.1 32 1 23.98l5.16-2.72 5.82 3.08 4.88-2.57-5.82-3.08 5.17-2.73 15.1 8.02-5.17 2.72-5.5-2.92-4.88 2.58 5.5 2.92L16.1 32Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 690 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@ -0,0 +1,11 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon" clip-path="url(#clip0_0_10)">
<path id="Vector" d="M5.5 10.0833C8.03131 10.0833 10.0833 8.03131 10.0833 5.5C10.0833 2.96869 8.03131 0.916667 5.5 0.916667C2.96869 0.916667 0.916667 2.96869 0.916667 5.5C0.916667 8.03131 2.96869 10.0833 5.5 10.0833Z" stroke="var(--stroke-0, #7A7068)" stroke-width="0.6875" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M5.5 2.75V5.5L7.33333 6.41667" stroke="var(--stroke-0, #7A7068)" stroke-width="0.6875" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_0_10">
<rect width="11" height="11" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 805 B

View File

@ -0,0 +1,11 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon" clip-path="url(#clip0_0_7)">
<path id="Vector" d="M5 9.16667C7.30119 9.16667 9.16667 7.30119 9.16667 5C9.16667 2.69881 7.30119 0.833333 5 0.833333C2.69881 0.833333 0.833333 2.69881 0.833333 5C0.833333 7.30119 2.69881 9.16667 5 9.16667Z" stroke="var(--stroke-0, #7A7068)" stroke-width="0.625" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M5 2.5V5L6.66667 5.83333" stroke="var(--stroke-0, #7A7068)" stroke-width="0.625" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_0_7">
<rect width="10" height="10" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 786 B

View File

@ -0,0 +1,6 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon">
<path id="Vector" d="M2.29167 5.5H8.70833" stroke="var(--stroke-0, #1A1A18)" stroke-width="0.6875" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M5.5 2.29167L8.70833 5.5L5.5 8.70833" stroke="var(--stroke-0, #1A1A18)" stroke-width="0.6875" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 510 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

View File

@ -0,0 +1,44 @@
/**
* Standard ABiiR eyebrow (Jost). Single line, full text (no clipping).
* `surface`: burgundy on light backdrops, cream on dark.
*/
export type AbiirEyebrowSurface = 'light' | 'dark';
const SURFACE_COLOR: Record<AbiirEyebrowSurface, string> = {
light: '#5D0B24',
dark: '#faf8f3',
};
export type AbiirEyebrowProps = {
children: React.ReactNode;
/** `light` = burgundy (#5D0B24). `dark` = cream (#faf8f3) for dark backdrops. */
surface?: AbiirEyebrowSurface;
className?: string;
as?: 'p' | 'span';
/**
* `uppercase` (default): CSS small caps style for standard eyebrows.
* `preserve`: no transform — use when the string must keep mixed case (e.g. ABiiR).
*/
textCase?: 'uppercase' | 'preserve';
};
export function AbiirEyebrow({
children,
surface = 'light',
className = '',
as: Tag = 'p',
textCase = 'uppercase',
}: AbiirEyebrowProps) {
/** ~8% smaller than 12/16.575/2.763 spec; nowrap keeps one line without truncation. */
const caseClass = textCase === 'preserve' ? 'normal-case' : 'uppercase';
const base =
`m-0 whitespace-nowrap text-center font-["Jost",sans-serif] text-[11px] font-medium not-italic ${caseClass} leading-[15.194px] tracking-[2.533px]`;
return (
<Tag
className={[base, className].filter(Boolean).join(' ')}
style={{color: SURFACE_COLOR[surface]}}
>
{children}
</Tag>
);
}

View File

@ -0,0 +1,319 @@
import {AbiirEyebrow} from '~/components/AbiirEyebrow';
import type {AbiirHomeHeroCms} from '~/lib/homeHero';
import {ABIIR_WIDE_CONTENT_SHELL_CLASS} from '~/lib/springGlowBanner';
import heroBrandSetNewSrc from '~/assets/hero-brand-set-new.png';
import heroBrandSetNewMobileSrc from '~/assets/hero-brand-set-new_mobile.png';
/** Figma hero band height + marquee. */
const FIGMA_HERO_H = 978;
const FIGMA_MARQUEE_H = 68;
/** Cropped mobile brand set (`hero-brand-set-new_mobile.png`, transparent padding removed). */
const MOBILE_HERO_IMAGE_W = 1092;
const MOBILE_HERO_IMAGE_H = 887;
/** Hero band background (left → right). */
const HERO_BG_GRADIENT_START = '#ae7f6b';
const HERO_BG_GRADIENT_END = '#cb9287';
function toHeroImageData(
input:
| {
id?: string | null;
url: string;
altText?: string | null;
width?: number | null;
height?: number | null;
}
| null
| undefined,
) {
if (!input?.url) return null;
return {
id: input.id ?? undefined,
url: input.url,
altText: input.altText ?? '',
width: input.width ?? undefined,
height: input.height ?? undefined,
};
}
const HERO_IMAGE_STATIC = toHeroImageData({
id: 'hero-brand-set-new',
url: heroBrandSetNewSrc,
altText: 'ABiiR Skincare — Hydra Serum, Moisturizer & Gua Sha',
width: 1710,
height: FIGMA_HERO_H,
});
function pickHeroImageData(cms: AbiirHomeHeroCms) {
if (cms.image?.url) {
return toHeroImageData({
id: cms.image.id,
url: cms.image.url,
altText: cms.image.alt,
width: cms.image.width,
height: cms.image.height,
});
}
return HERO_IMAGE_STATIC;
}
function isExternalHref(href: string) {
return /^https?:\/\//i.test(href);
}
function HeroCtaLink({
href,
className,
style,
children,
}: {
href: string;
className?: string;
style?: React.CSSProperties;
children: React.ReactNode;
}) {
return (
<a
href={href}
className={className}
style={style}
rel={isExternalHref(href) ? 'noreferrer' : undefined}
>
{children}
</a>
);
}
/** Figma icon564:2080 — 15×15 */
function HeroCtaArrowIcon({className}: {className?: string}) {
return (
<svg
className={className}
width={15}
height={15}
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<path
d="M1 7.5h11M8.5 1L14 7.5 8.5 14"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export type AbiirHomeHeroProps = {
cms: AbiirHomeHeroCms;
};
/**
* Hero (legacy layout): left copy + brand set image — desktop `hero-brand-set-new.png`,
* mobile (< lg) `hero-brand-set-new_mobile.png`. CMS hero image stays single-src for all breakpoints.
* Band background: linear `#ae7f6b` → `#cb9277`. Radial `#eac5a5` glow behind the image (tight radius).
*/
export function AbiirHomeHero({cms}: AbiirHomeHeroProps) {
const heroBurgundy = cms.accentBurgundy;
const usesStaticHeroImage = !cms.image?.url;
const imageData = pickHeroImageData(cms);
const heroGradient = `linear-gradient(to right, ${HERO_BG_GRADIENT_START}, ${HERO_BG_GRADIENT_END})`;
/** Behind the photo: smaller circle, `#eac5a5` strongest at center, transparent at outer edge. */
const imageRadialGlow = `radial-gradient(circle min(32vw, 260px) at 50% 50%, rgba(234,197,165,0.68) 0%, rgba(234,197,165,0.28) 48%, rgba(234,197,165,0.06) 72%, transparent 100%)`;
return (
<section
data-abiir-hero
className="abiir-home-hero relative w-full overflow-hidden !py-0"
style={{background: heroGradient}}
>
{/* Soft light orbs (legacy Figma accents) */}
<div
aria-hidden
className="pointer-events-none absolute hidden rounded-full lg:block"
style={{
width: 'min(696px, 55vw)',
height: 'min(696px, 55vw)',
left: '8%',
top: '-18%',
background:
'radial-gradient(circle, rgba(250,248,243,0.22) 0%, transparent 68%)',
filter: 'blur(28px)',
}}
/>
<div
aria-hidden
className="pointer-events-none absolute hidden rounded-full lg:block"
style={{
width: 'min(520px, 42vw)',
height: 'min(520px, 42vw)',
right: '-8%',
top: '-12%',
background:
'radial-gradient(circle, rgba(250,248,243,0.18) 0%, transparent 70%)',
filter: 'blur(32px)',
}}
/>
<div
aria-hidden
className="pointer-events-none absolute hidden rounded-full lg:block"
style={{
width: 'min(480px, 40vw)',
height: 'min(480px, 40vw)',
right: '4%',
bottom: '-14%',
background:
'radial-gradient(circle, rgba(60,6,23,0.12) 0%, transparent 72%)',
filter: 'blur(36px)',
}}
/>
<div className={`relative ${ABIIR_WIDE_CONTENT_SHELL_CLASS}`}>
<div className="flex flex-col gap-0 lg:flex-row lg:items-stretch lg:min-h-[min(978px,92vh)]">
{/* Copy column */}
<div className="relative z-10 order-2 flex w-full flex-col justify-center pb-14 pt-6 sm:pb-16 sm:pt-8 lg:order-1 lg:w-[42%] lg:max-w-[637px] lg:py-20 xl:w-[40%]">
<div className="flex flex-col gap-6">
<AbiirEyebrow surface="light" className="!text-left">
{cms.eyebrow}
</AbiirEyebrow>
<h1
className="m-0 font-['Cormorant_Garamond',serif] font-light tracking-[-2.04px] text-white not-italic"
style={{
fontSize: 'clamp(2.75rem, 9.5vw, 6.375rem)',
lineHeight: 1.08,
fontWeight: 300,
}}
>
<span className="block">
{cms.headlineLine1Prefix}
<span className="italic">{cms.headlineLine1Italic}</span>
</span>
<span className="block">
{cms.headlineLine2Prefix}
<span style={{color: heroBurgundy}}>
{cms.headlineLine2Accent}
</span>
</span>
</h1>
<p className="m-0 max-w-xl font-['Raleway',sans-serif] text-[clamp(1rem,2.2vw,22px)] font-medium leading-[34px] text-[rgba(250,248,243,0.8)]">
{cms.body}
</p>
<div className="flex flex-wrap items-start gap-[21.25px] pt-1">
<HeroCtaLink
href={cms.primaryCtaHref}
className="group relative inline-flex min-h-[52.883px] items-center justify-between gap-3 pl-[36px] pr-[28px] no-underline transition-opacity duration-300 hover:opacity-[0.92]"
style={{background: heroBurgundy, color: '#fff'}}
>
<span className="font-['Raleway',sans-serif] text-[14px] font-bold uppercase leading-[19.89px] tracking-[2.3868px]">
{cms.primaryCtaLabel}
</span>
<HeroCtaArrowIcon className="shrink-0 text-white transition-transform group-hover:translate-x-0.5" />
</HeroCtaLink>
<HeroCtaLink
href={cms.secondaryCtaHref}
className="inline-flex min-h-[52.883px] items-center justify-center border-0 border-b border-solid border-white bg-transparent pb-[17px] pt-4 no-underline transition-opacity duration-300 hover:opacity-80"
>
<span className="whitespace-nowrap text-left font-['Raleway',sans-serif] text-[14px] font-medium uppercase leading-[19.89px] tracking-[2.3868px] text-white">
{cms.secondaryCtaLabel}
</span>
</HeroCtaLink>
</div>
</div>
</div>
{/* Image column — mobile: full-bleed cropped asset; lg+: cover + radial glow */}
<div className="relative z-[1] order-1 w-[calc(100%+3rem)] max-w-none -mx-6 sm:w-[calc(100%+5rem)] sm:-mx-10 lg:order-2 lg:mx-0 lg:w-full lg:max-w-none lg:flex-1">
<div className="relative w-full overflow-hidden lg:absolute lg:inset-0 lg:h-full lg:right-[-8%] xl:right-[-12%]">
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-[1] hidden lg:block"
style={{background: imageRadialGlow}}
/>
{imageData ? (
usesStaticHeroImage ? (
<>
<img
src={heroBrandSetNewMobileSrc}
alt={imageData.altText || 'ABiiR Skincare — hero'}
className="relative z-[2] block w-full h-auto lg:hidden"
width={MOBILE_HERO_IMAGE_W}
height={MOBILE_HERO_IMAGE_H}
loading="eager"
decoding="async"
sizes="100vw"
/>
<img
src={imageData.url}
alt={imageData.altText || 'ABiiR Skincare — hero'}
className="relative z-[2] hidden h-full w-full object-cover object-[55%_center] lg:block"
width={imageData.width ?? 1710}
height={imageData.height ?? FIGMA_HERO_H}
loading="eager"
decoding="async"
sizes="60vw"
/>
</>
) : (
<>
<img
src={imageData.url}
alt={imageData.altText || 'ABiiR Skincare — hero'}
className="relative z-[2] block aspect-[1092/887] w-full object-cover object-center lg:hidden"
loading="eager"
decoding="async"
sizes="100vw"
/>
<img
src={imageData.url}
alt=""
aria-hidden
className="relative z-[2] hidden h-full w-full object-cover object-[55%_center] lg:block"
width={imageData.width ?? 1710}
height={imageData.height ?? FIGMA_HERO_H}
loading="eager"
decoding="async"
sizes="60vw"
/>
</>
)
) : null}
</div>
</div>
</div>
</div>
{/* Marquee — full width */}
<div
className="relative z-10 w-full overflow-hidden pt-[21.25px]"
style={{background: heroBurgundy, minHeight: FIGMA_MARQUEE_H}}
>
<div className="flex h-[26px] w-full items-center overflow-hidden">
<div className="h-full min-h-0 w-full overflow-hidden opacity-75">
<div
className="animate-[hero-marquee_32s_linear_infinite] flex w-max items-center gap-[200px] whitespace-nowrap font-['Cormorant_Garamond',serif] text-[rgba(250,248,243,0.8)] font-semibold italic tracking-[1.02px]"
style={{
fontSize: 'clamp(1.4rem, calc(1.974vw + 0.7rem), 33.6px)',
lineHeight: '26px',
}}
>
{[0, 1].map((i) => (
<p key={i} className="m-0 shrink-0" aria-hidden={i === 1}>
{cms.marqueeText}
</p>
))}
</div>
</div>
</div>
</div>
</section>
);
}
export type {AbiirHomeHeroCms} from '~/lib/homeHero';

View File

@ -0,0 +1,259 @@
import {AbiirEyebrow} from '~/components/AbiirEyebrow';
import type {AbiirHomeHeroCms} from '~/lib/homeHero';
import {ABIIR_WIDE_INNER_FRAME_CLASS} from '~/lib/springGlowBanner';
import heroOption04Src from '~/assets/Hero_option04.jpg';
/** Figma hero band height + marquee. */
const FIGMA_HERO_H = 978;
const FIGMA_MARQUEE_H = 68;
function toHeroImageData(
input:
| {
id?: string | null;
url: string;
altText?: string | null;
width?: number | null;
height?: number | null;
}
| null
| undefined,
) {
if (!input?.url) return null;
return {
id: input.id ?? undefined,
url: input.url,
altText: input.altText ?? '',
width: input.width ?? undefined,
height: input.height ?? undefined,
};
}
const HERO_IMAGE_STATIC = toHeroImageData({
id: 'hero-option-04',
url: heroOption04Src,
altText: 'ABiiR skincare — hero',
width: 855,
height: FIGMA_HERO_H,
});
function pickHeroImageData(cms: AbiirHomeHeroCms) {
if (cms.image?.url) {
return toHeroImageData({
id: cms.image.id,
url: cms.image.url,
altText: cms.image.alt,
width: cms.image.width,
height: cms.image.height,
});
}
return HERO_IMAGE_STATIC;
}
function isExternalHref(href: string) {
return /^https?:\/\//i.test(href);
}
function HeroCtaLink({
href,
className,
style,
children,
}: {
href: string;
className?: string;
style?: React.CSSProperties;
children: React.ReactNode;
}) {
return (
<a
href={href}
className={className}
style={style}
rel={isExternalHref(href) ? 'noreferrer' : undefined}
>
{children}
</a>
);
}
/** Figma icon564:2080 — 15×15 */
function HeroCtaArrowIcon({className}: {className?: string}) {
return (
<svg
className={className}
width={15}
height={15}
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<path
d="M1 7.5h11M8.5 1L14 7.5 8.5 14"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export type AbiirHomeHeroProps = {
cms: AbiirHomeHeroCms;
};
/**
* Hero: Figma `633:2179` — 1710×978 frame, image `absolute inset-0 object-cover`, copy
* `left: 206px`, `width: 637px`, vertically centered on `lg` (unpadded 1710 rail).
* Viewport gutters extend `gradientStart` / `gradientEnd` past the frame.
*/
export function AbiirHomeHero({cms}: AbiirHomeHeroProps) {
const heroBurgundy = cms.accentBurgundy;
const imageData = pickHeroImageData(cms);
const edgeLeft = cms.gradientStart;
const edgeRight = cms.gradientEnd;
/** Space outside the centered 1710px shell (matches Spring Glow full-bleed gutters). */
const viewportGutterWidth = 'max(0px, calc((100vw - min(100vw, 1710px)) / 2))';
return (
<section data-abiir-hero className="abiir-home-hero relative w-full overflow-hidden !py-0">
<div
className="relative w-full overflow-hidden lg:h-[978px]"
style={{
minHeight: `min(100svh, ${FIGMA_HERO_H}px)`,
}}
>
{/* Viewport → shell: edge colors (desktop only; mobile uses column gradient below) */}
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 z-0 hidden lg:block"
style={{width: viewportGutterWidth, backgroundColor: edgeLeft}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 z-0 hidden lg:block"
style={{width: viewportGutterWidth, backgroundColor: edgeRight}}
/>
<div className={`relative z-[1] ${ABIIR_WIDE_INNER_FRAME_CLASS}`}>
{/* Full-bleed within 1710px frame (Figma has no side padding on this artboard) */}
<div
className="relative min-h-[min(100svh,978px)] w-full overflow-hidden bg-gradient-to-r from-[var(--abiir-hero-edge-l)] to-[var(--abiir-hero-edge-r)] lg:h-[978px] lg:min-h-[978px] lg:bg-transparent"
style={
{
'--abiir-hero-edge-l': edgeLeft,
'--abiir-hero-edge-r': edgeRight,
} as React.CSSProperties
}
>
{imageData ? (
<img
src={imageData.url}
alt={imageData.altText || 'ABiiR hero'}
width={imageData.width ?? 855}
height={imageData.height ?? FIGMA_HERO_H}
loading="eager"
decoding="async"
className="absolute inset-0 size-full object-cover object-center"
sizes="(min-width: 1024px) 1710px, 100vw"
/>
) : null}
</div>
{/* Readability: scrim over the left half of the shell */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-[5] bg-gradient-to-r from-[rgba(60,6,23,0.48)] via-[rgba(60,6,23,0.12)] to-transparent sm:from-[rgba(60,6,23,0.4)] sm:via-[rgba(60,6,23,0.08)] lg:max-w-[50%]"
/>
{/* Copy — lg+: left 206px, w 637px; vertically centered in 978px frame */}
<div className="pointer-events-none absolute inset-0 z-10">
<div className="pointer-events-auto relative px-6 pb-12 pt-10 sm:px-10 lg:absolute lg:left-[206px] lg:top-1/2 lg:w-[637px] lg:max-w-[637px] lg:-translate-y-1/2 lg:px-0 lg:pb-0 lg:pt-0">
<div className="flex flex-col gap-6">
<AbiirEyebrow surface="light" className="!text-left">
{cms.eyebrow}
</AbiirEyebrow>
<h1
className="m-0 font-['Cormorant_Garamond',serif] font-light tracking-[-0.969px] text-white not-italic"
style={{
fontSize: 'clamp(2.5rem, 5.5vw, 64.6px)',
lineHeight: 1.12,
fontWeight: 300,
}}
>
<span className="block w-full">
<span className="font-light">{cms.headlineLine1Prefix}</span>
<span className="font-['Cormorant_Garamond',serif] font-light italic">
{cms.headlineLine1Italic}
</span>
</span>
<span className="block w-full">
<span className="font-light">{cms.headlineLine2Prefix}</span>
<span className="font-light not-italic" style={{color: heroBurgundy}}>
{cms.headlineLine2Accent}
</span>
</span>
</h1>
<p className="m-0 font-['Raleway',sans-serif] text-[clamp(1rem,1.29vw+0.65rem,22px)] font-medium leading-[34px] text-[rgba(250,248,243,0.8)] lg:text-[22px]">
{cms.body}
</p>
<div className="flex flex-wrap items-start gap-[21.25px]">
<HeroCtaLink
href={cms.primaryCtaHref}
className="group relative inline-flex h-[52.883px] w-full max-w-[257.586px] shrink-0 items-center justify-between pl-[36px] pr-[18px] no-underline transition-opacity duration-300 hover:opacity-[0.92]"
style={{background: heroBurgundy, color: '#fff'}}
>
<span className="font-['Raleway',sans-serif] text-[14px] font-bold uppercase leading-[19.89px] tracking-[2.3868px]">
{cms.primaryCtaLabel}
</span>
<HeroCtaArrowIcon className="shrink-0 text-white transition-transform group-hover:translate-x-0.5" />
</HeroCtaLink>
<HeroCtaLink
href={cms.secondaryCtaHref}
className="inline-flex h-[52.883px] min-w-0 shrink-0 cursor-pointer items-center justify-center border-0 border-b border-solid border-white bg-transparent pb-[17px] pt-4 no-underline transition-opacity duration-300 hover:opacity-80 lg:min-w-[96.328px]"
>
<span className="whitespace-nowrap text-left font-['Raleway',sans-serif] text-[14px] font-medium uppercase leading-[19.89px] tracking-[2.3868px] text-white">
{cms.secondaryCtaLabel}
</span>
</HeroCtaLink>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Marquee — full width */}
<div
className="relative z-10 w-full overflow-hidden pt-[21.25px]"
style={{background: heroBurgundy, minHeight: FIGMA_MARQUEE_H}}
>
<div className="flex h-[26px] w-full items-center overflow-hidden">
<div className="h-full min-h-0 w-full overflow-hidden opacity-75">
<div
className="animate-[hero-marquee_32s_linear_infinite] flex w-max items-center gap-[200px] whitespace-nowrap font-['Cormorant_Garamond',serif] text-[rgba(250,248,243,0.8)] font-semibold italic tracking-[1.02px]"
style={{
fontSize: 'clamp(1.4rem, calc(1.974vw + 0.7rem), 33.6px)',
lineHeight: '26px',
}}
>
{[0, 1].map((i) => (
<p key={i} className="m-0 shrink-0" aria-hidden={i === 1}>
{cms.marqueeText}
</p>
))}
</div>
</div>
</div>
</div>
</section>
);
}
export type {AbiirHomeHeroCms} from '~/lib/homeHero';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,377 @@
import {useCallback, type MouseEvent} from 'react';
import {Link} from 'react-router';
import {CartForm, Image, Money} from '@shopify/hydrogen';
import {motion} from 'motion/react';
import {Heart, ShoppingBag, Star} from 'lucide-react';
import {useAbiirProductWishlist} from '~/hooks/useAbiirProductWishlist';
import {useVariantUrl} from '~/lib/variants';
import type {AbiirProductListItemData} from '~/lib/featuredProducts';
const gold = '#b39763';
const badgeGold = '#b8a042';
const burgundy = '#5d0b24';
const imageBg = '#ede8e0';
const chipBg = '#f5f1e8';
const muted = '#5a5550';
const charcoal = '#1a1a18';
const cream = '#f8f4ef';
const overlayBurgundy = 'rgba(93, 11, 36, 0.95)';
function productBadges(
item: AbiirProductListItemData,
): {label: string; variant: 'burgundy' | 'gold'}[] {
if (item.hideBadge) return [];
const out: {label: string; variant: 'burgundy' | 'gold'}[] = [];
const lower = item.tags.map((t) => t.toLowerCase());
if (lower.some((t) => t.includes('bestseller'))) {
out.push({label: 'Bestseller', variant: 'burgundy'});
}
if (
item.compareAtPrice &&
parseFloat(item.compareAtPrice.amount) > parseFloat(item.price.amount)
) {
out.push({label: 'Sale', variant: 'burgundy'});
}
if (lower.some((t) => t === 'new' || t.includes('new'))) {
out.push({label: 'New', variant: 'gold'});
}
return out;
}
export type AbiirProductListItemProps = {
item: AbiirProductListItemData;
index?: number;
/** Heart on the image; default `true`. */
showWishlist?: boolean;
/** Replaces the default built-in wishlist click handler if set. */
onToggleWishlist?: (e: MouseEvent<HTMLButtonElement>) => void;
/** Image hover strip + Add to Bag */
showQuickAdd?: boolean;
onQuickAdd?: (e: MouseEvent<HTMLButtonElement>) => void;
/**
* `homepageFeatured` — homepage “Most Loved” strip only: no corner badges, review row, or benefit chips
* (CMS can carry demo ratings/tags that do not match the PDP).
*/
variant?: 'default' | 'homepageFeatured';
};
/**
* Pixel-matched product card from `React/src/app/components/FeaturedProductCard.tsx`.
* Uses Shopify `Image` + `Money`; links to `/products/:handle`.
*/
export function AbiirProductListItem({
item,
index = 0,
showWishlist = true,
onToggleWishlist: onToggleWishlistProp,
showQuickAdd = true,
onQuickAdd,
variant = 'default',
}: AbiirProductListItemProps) {
const {wishlisted, toggleWishlist} = useAbiirProductWishlist(
item.handle?.trim() ?? '',
);
const onWishlistButtonClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (onToggleWishlistProp) {
onToggleWishlistProp(e);
return;
}
toggleWishlist();
},
[onToggleWishlistProp, toggleWishlist],
);
const variantUrl = useVariantUrl(item.handle);
const badges = productBadges(item);
const isHomeFeatured = variant === 'homepageFeatured';
const showImageBadges = !isHomeFeatured && badges.length > 0;
const hasReviewData = item.rating > 0 && item.reviewCount > 0;
const showReviewsRow = !isHomeFeatured && hasReviewData;
const showBenefitChips = !isHomeFeatured && item.benefits.length > 0;
const starCount =
item.rating >= 4.75
? 5
: Math.max(0, Math.min(5, Math.round(item.rating)));
const averageLabel = (() => {
if (!Number.isFinite(item.rating) || item.rating <= 0) return '';
const x = Math.round(item.rating * 10) / 10;
return Number.isInteger(x) ? String(x) : x.toFixed(1);
})();
const countLabel = item.reviewCount === 1 ? '1 review' : `${item.reviewCount} reviews`;
const imageData = item.imageForCard;
const canQuickAdd = Boolean(item.firstVariantId) && item.availableForSale !== false;
const onQuickAddButtonClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
// Prevent the surrounding product `Link` from navigating.
e.preventDefault();
e.stopPropagation();
onQuickAdd?.(e);
},
[onQuickAdd],
);
return (
<motion.div
initial={false}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true, margin: '-40px'}}
transition={{duration: 0.55, delay: index * 0.07}}
className="w-full"
>
<div className="group w-full">
<div
className="relative w-full overflow-hidden"
style={{
background: imageBg,
aspectRatio: '299 / 398.664',
}}
>
<Link
to={variantUrl}
prefetch="intent"
tabIndex={-1}
className="absolute inset-0 z-0 block cursor-pointer no-underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5d0b24] focus-visible:ring-offset-2"
aria-label={`View ${item.displayTitle}`}
>
{imageData ? (
<Image
alt=""
className="pointer-events-none absolute inset-0 size-full object-cover"
data={imageData}
loading={index < 2 ? 'eager' : 'lazy'}
sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw"
/>
) : null}
</Link>
{showImageBadges ? (
<div
className="pointer-events-none absolute z-[1] flex max-w-[calc(100%-34px)] flex-wrap items-center gap-2"
style={{left: '17px', top: '17px'}}
>
{badges.map((b) => (
<span
key={b.label}
className="font-['Raleway',sans-serif] text-[10.54px] font-semibold uppercase leading-[15.81px] tracking-[1.6864px]"
style={{
background: b.variant === 'gold' ? badgeGold : burgundy,
color: cream,
padding: '5.88px 12.75px',
}}
>
{b.label}
</span>
))}
</div>
) : null}
{showWishlist ? (
<button
type="button"
onClick={onWishlistButtonClick}
className="absolute top-[17px] right-[17px] z-[4] cursor-pointer p-2.5 transition-opacity duration-200 hover:opacity-80"
style={{
background: 'rgba(250, 248, 243, 0.92)',
backdropFilter: 'blur(4px)',
color: wishlisted ? burgundy : charcoal,
border: 'none',
}}
aria-label={
wishlisted ? 'Remove from wishlist' : 'Add to wishlist'
}
>
<Heart
size={15}
strokeWidth={1.5}
fill={wishlisted ? burgundy : 'none'}
/>
</button>
) : null}
{showQuickAdd ? (
<div
className="absolute inset-x-0 bottom-0 z-[2] translate-y-full transition-transform duration-300 ease-out group-hover:translate-y-0 group-focus-within:translate-y-0"
style={{
background: overlayBurgundy,
padding: '17px',
}}
>
{onQuickAdd ? (
<button
type="button"
onClick={onQuickAddButtonClick}
className="relative flex h-[48.109px] w-full cursor-pointer items-center justify-center gap-2 border-none transition-opacity duration-200 hover:opacity-92"
style={{
background: burgundy,
fontFamily: "'Raleway', sans-serif",
fontSize: '12.24px',
fontWeight: 600,
letterSpacing: '2.2032px',
color: cream,
textTransform: 'uppercase',
}}
>
<ShoppingBag size={14} strokeWidth={1.75} aria-hidden />
Add to Cart
</button>
) : item.firstVariantId ? (
<CartForm
route="/cart"
action={CartForm.ACTIONS.LinesAdd}
inputs={{
lines: [{merchandiseId: item.firstVariantId, quantity: 1}],
}}
>
{(fetcher) => (
<button
type="submit"
disabled={!canQuickAdd || fetcher.state !== 'idle'}
className="relative flex h-[48.109px] w-full cursor-pointer items-center justify-center gap-2 border-none transition-opacity duration-200 hover:opacity-92 disabled:cursor-not-allowed disabled:opacity-60"
style={{
background: burgundy,
fontFamily: "'Raleway', sans-serif",
fontSize: '12.24px',
fontWeight: 600,
letterSpacing: '2.2032px',
color: cream,
textTransform: 'uppercase',
cursor: canQuickAdd ? 'pointer' : 'not-allowed',
}}
>
<ShoppingBag size={14} strokeWidth={1.75} aria-hidden />
Add to Cart
</button>
)}
</CartForm>
) : (
<button
type="button"
disabled
className="relative flex h-[48.109px] w-full cursor-not-allowed items-center justify-center gap-2 border-none opacity-60"
style={{
background: burgundy,
fontFamily: "'Raleway', sans-serif",
fontSize: '12.24px',
fontWeight: 600,
letterSpacing: '2.2032px',
color: cream,
textTransform: 'uppercase',
}}
>
<ShoppingBag size={14} strokeWidth={1.75} aria-hidden />
Add to Cart
</button>
)}
</div>
) : null}
</div>
<Link
to={variantUrl}
prefetch="intent"
className="mt-0 block cursor-pointer pt-[21px] no-underline"
>
<div className="w-full">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<p
className="m-0 font-['Raleway',sans-serif] text-[11.05px] font-medium leading-[19.89px] tracking-[1.6575px] not-italic normal-case"
style={{color: muted}}
>
{item.subtitle}
</p>
<h3
className="m-0 font-['Cormorant_Garamond',serif] text-[24px] font-medium leading-[24.48px] tracking-[-0.204px] not-italic"
style={{color: charcoal}}
>
{item.displayTitle}
</h3>
</div>
<div className="shrink-0 pt-1 text-right">
{item.compareAtPrice ? (
<div
className="m-0 font-['Raleway',sans-serif] text-[13.94px] font-light leading-[13.94px] line-through not-italic"
style={{color: muted}}
>
<Money data={item.compareAtPrice} />
</div>
) : null}
<div
className="m-0 font-['Raleway',sans-serif] text-[20px] font-medium leading-[1.2] not-italic"
style={{
color: item.compareAtPrice ? burgundy : charcoal,
marginTop: item.compareAtPrice ? '2px' : 0,
}}
>
<Money data={item.price} />
</div>
</div>
</div>
{showReviewsRow ? (
<div
className="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1"
role="img"
aria-label={
averageLabel
? `Rated ${averageLabel} out of 5, ${countLabel}`
: countLabel
}
>
<div className="flex items-center gap-[2.125px]">
{Array.from({length: 5}).map((_, i) => (
<Star
key={i}
size={11}
strokeWidth={0}
fill={i < starCount ? gold : '#D4C5B0'}
aria-hidden
/>
))}
</div>
{averageLabel ? (
<span
className="font-['Raleway',sans-serif] text-[12.5px] font-medium leading-[1.1] not-italic"
style={{color: charcoal}}
aria-hidden
>
{averageLabel}
</span>
) : null}
<span
className="font-['Raleway',sans-serif] text-[12.24px] font-normal leading-[18.36px] not-italic"
style={{color: muted}}
aria-hidden
>
({countLabel})
</span>
</div>
) : null}
{showBenefitChips ? (
<div className="mt-4 flex flex-wrap gap-[6px]">
{item.benefits.map((benefit) => (
<span
key={benefit}
className="inline-block font-['Raleway',sans-serif] text-[10.2px] font-medium uppercase leading-[15.3px] tracking-[1.02px] not-italic"
style={{
background: chipBg,
color: muted,
padding: '4.75px 10.63px',
}}
>
{benefit}
</span>
))}
</div>
) : null}
</div>
</Link>
</div>
</motion.div>
);
}

View File

@ -0,0 +1,202 @@
import {useMemo, useState} from 'react';
import {Link} from 'react-router';
import {CartForm} from '@shopify/hydrogen';
import {AbiirProductListItem} from '~/components/AbiirProductListItem';
import {Shell} from '~/components/AbiirStorePages';
import type {AbiirInfoPageCms} from '~/lib/abiirStaticPages';
import {ABIIR_PRODUCT_LIST_GRID_CLASS} from '~/lib/abiirProductListGrid';
import type {AbiirProductListItemData} from '~/lib/featuredProducts';
import {
ABIIR_QUIZ_PRODUCT_HANDLES_IN_ORDER,
ABIIR_SKINCARE_QUIZ_QUESTIONS,
getMatchingQuizProductHandles,
type QuizLetter,
} from '~/lib/abiirSkincareQuiz';
const TOTAL = 7;
const optionButtonClass =
"flex w-full cursor-pointer items-center gap-3 border border-[rgba(26,26,24,0.12)] bg-[#f8f4ef] px-4 py-3.5 text-left font-['Jost',sans-serif] text-[0.95rem] font-normal not-italic leading-snug text-[#1a1a18] transition-colors hover:border-[#c17d5c] hover:bg-[#ede8e0] sm:px-5 sm:py-4";
const optionLetterClass =
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-[rgba(26,26,24,0.15)] bg-[#ede8e0] font-['Jost',sans-serif] text-[0.75rem] font-medium text-[#5d0b24]";
type Props = {
cms: AbiirInfoPageCms;
quizProducts: AbiirProductListItemData[];
};
export function AbiirSkincareQuiz({cms, quizProducts}: Props) {
const [step, setStep] = useState(0);
/** Index 0..6 → selected letter when that question was answered */
const [answers, setAnswers] = useState<(QuizLetter | null)[]>(() =>
Array.from({length: TOTAL}, () => null),
);
const isResults = step >= TOTAL;
const productByHandle = useMemo(() => {
const m = new Map<string, AbiirProductListItemData>();
for (const row of quizProducts) {
const h = row.handle?.trim();
if (h) m.set(h, row);
}
return m;
}, [quizProducts]);
const recommendedRows = useMemo(() => {
if (!isResults) return [];
if (answers.some((a) => a == null)) return [];
const letters = answers as readonly QuizLetter[];
const handles = getMatchingQuizProductHandles(letters);
const set = new Set(handles);
const out: AbiirProductListItemData[] = [];
for (const h of ABIIR_QUIZ_PRODUCT_HANDLES_IN_ORDER) {
if (set.has(h)) {
const row = productByHandle.get(h);
if (row) out.push(row);
}
}
return out;
}, [answers, isResults, productByHandle]);
const linesToAddAll = useMemo(
() =>
recommendedRows
.filter((r) => Boolean(r.firstVariantId) && r.availableForSale !== false)
.map((r) => ({merchandiseId: r.firstVariantId as string, quantity: 1})),
[recommendedRows],
);
function choose(letter: QuizLetter) {
setAnswers((prev) => {
const next = [...prev];
next[step] = letter;
return next;
});
setStep((s) => s + 1);
}
function goBack() {
if (step <= 0) return;
if (isResults) {
setStep(TOTAL - 1);
return;
}
setStep((s) => s - 1);
setAnswers((prev) => {
const next = [...prev];
next[step - 1] = null;
return next;
});
}
const q = !isResults ? ABIIR_SKINCARE_QUIZ_QUESTIONS[step] : null;
return (
<Shell eyebrow="Help" subtitle={cms.intro || undefined} title={cms.title}>
<div
className={`mx-auto w-full text-left ${isResults ? 'max-w-[1200px]' : 'max-w-[640px]'}`}
>
{!isResults && q ? (
<>
<p className="m-0 font-['Jost',sans-serif] text-[0.7rem] font-medium uppercase not-italic tracking-[0.14em] text-[#c17d5c]">
Question {step + 1} of {TOTAL}
</p>
<h2 className="m-0 mt-3 font-['Cormorant_Garamond',serif] text-[1.65rem] font-light not-italic leading-tight tracking-[-0.02em] text-[#1a1a18] sm:text-[1.85rem]">
{q.prompt}
</h2>
<div className="mt-8 flex flex-col gap-2.5 sm:gap-3">
{q.options.map((opt) => (
<button
className={optionButtonClass}
key={opt.letter}
onClick={() => choose(opt.letter)}
type="button"
>
<span className={optionLetterClass}>{opt.letter}</span>
<span className="min-w-0 flex-1">{opt.label}</span>
</button>
))}
</div>
{step > 0 ? (
<div className="mt-10">
<button
className="cursor-pointer border-0 bg-transparent font-['Jost',sans-serif] text-[0.8rem] font-medium uppercase tracking-[0.12em] text-[#7a7068] underline-offset-4 transition-colors hover:text-[#1a1a18] hover:underline"
onClick={goBack}
type="button"
>
Back
</button>
</div>
) : null}
</>
) : (
<>
<h2 className="m-0 font-['Cormorant_Garamond',serif] text-[1.65rem] font-light not-italic leading-tight tracking-[-0.02em] text-[#1a1a18] sm:text-[1.85rem]">
Your picks
</h2>
<p className="m-0 mt-3 font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.55] text-[#5a5550]">
Based on your answers, here are products we think you will love.
</p>
{recommendedRows.length ? (
<div className={`${ABIIR_PRODUCT_LIST_GRID_CLASS} mt-10`}>
{recommendedRows.map((item, index) => (
<AbiirProductListItem index={index} item={item} key={item.handle} />
))}
</div>
) : (
<p className="m-0 mt-6 font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.55] text-[#5a5550]">
We could not match a preset bundle to every combination. Explore the full line or
reach out—we are happy to help you build a ritual.
</p>
)}
<div className="mt-10 flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-center sm:gap-5">
{linesToAddAll.length ? (
<CartForm
route="/cart"
action={CartForm.ACTIONS.LinesAdd}
className="w-full sm:w-auto"
inputs={{lines: linesToAddAll}}
>
{(fetcher) => (
<button
className="flex min-h-[48px] w-full cursor-pointer items-center justify-center border border-[#5d0b24] bg-[#5d0b24] px-7 py-3 font-['Raleway',sans-serif] text-[12px] font-semibold uppercase tracking-[2.2px] text-[#f8f4ef] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 sm:inline-flex sm:w-auto"
disabled={fetcher.state !== 'idle'}
type="submit"
>
Add all products to Cart
</button>
)}
</CartForm>
) : (
<button
className="flex min-h-[48px] w-full cursor-not-allowed items-center justify-center border border-[rgba(26,26,24,0.2)] bg-[#ede8e0] px-7 py-3 font-['Raleway',sans-serif] text-[12px] font-semibold uppercase tracking-[2.2px] text-[#7a7068] opacity-70 sm:inline-flex sm:w-auto"
disabled
type="button"
>
Add all products to Cart
</button>
)}
<Link
className="inline-flex min-h-[48px] cursor-pointer items-center justify-center border border-[rgba(26,26,24,0.12)] bg-[#ede8e0] px-7 py-3 font-['Jost',sans-serif] text-[0.75rem] font-medium uppercase tracking-[0.14em] text-[#1a1a18] no-underline transition-colors hover:border-[#c17d5c] hover:bg-[#e5dfd4]"
to="/collections/all"
>
Shop all products
</Link>
</div>
<div className="mt-6">
<button
className="cursor-pointer border-0 bg-transparent font-['Jost',sans-serif] text-[0.8rem] font-medium uppercase tracking-[0.12em] text-[#7a7068] underline-offset-4 transition-colors hover:text-[#1a1a18] hover:underline"
onClick={goBack}
type="button"
>
Back to last question
</button>
</div>
</>
)}
</div>
</Shell>
);
}

View File

@ -0,0 +1,684 @@
import {useState} from 'react';
import {useFetcher, Link} from 'react-router';
import {
ChevronDown,
Clock,
Instagram,
Mail,
MessageCircle,
Package,
RotateCcw,
Truck,
Youtube,
X,
} from 'lucide-react';
import type {
AbiirContactPageCms,
AbiirContactReachRowCms,
AbiirLegalPageCms,
AbiirFaqPageCms,
AbiirInfoPageCms,
AbiirPressItemCms,
AbiirPressPageCms,
AbiirShippingPageCms,
} from '~/lib/abiirStaticPages';
import {
abiirFormErrorTextClass,
abiirFormPrimaryButtonClass,
abiirFormPrimaryButtonStyle,
abiirFormStackClass,
contactInputClass,
contactLabelClass,
} from '~/lib/abiirFormTheme';
const accent = '#c17d5c';
const burgundy = '#5d0b24';
/**
* Static pages: center-aligned hero band + full-width main column (body copy stays left in children).
*/
export function Shell({
title,
eyebrow = 'Help',
subtitle,
eyebrowTone = 'default',
children,
}: {
title: string;
/** Upper label above the title — matches collection “eyebrow” (e.g. Our Collection) */
eyebrow?: string;
/** Figma: Raleway 16 / #5a5550 (FAQ band) */
subtitle?: string;
/** `accent` = Support label (#c17d5c, Jost); `default` = Help band (burgundy Raleway) */
eyebrowTone?: 'default' | 'accent';
children: React.ReactNode;
}) {
const eyebrowClass =
eyebrowTone === 'accent'
? "m-0 font-['Jost',sans-serif] text-[11px] font-medium uppercase not-italic leading-[1.1] tracking-[2.76px] text-[#c17d5c]"
: "m-0 font-['Raleway',sans-serif] text-[0.75rem] font-medium uppercase not-italic leading-[1.1] tracking-[0.32em] text-[#5d0b24]";
return (
<div className="w-full min-h-screen bg-[#f8f4ef]">
<header className="w-full border-b border-[rgba(26,26,24,0.08)] bg-[#ede8e0] pt-[40px] pb-10 sm:pt-[48px] sm:pb-12">
<div className="mx-auto flex w-full max-w-[1710px] flex-col items-center px-6 text-center sm:px-10 lg:px-16 2xl:px-[206px]">
<p className={eyebrowClass}>{eyebrow}</p>
<h1 className="m-0 mt-3 w-full text-center font-['Cormorant_Garamond',serif] text-[2.5rem] font-light not-italic leading-[1.08] tracking-[-0.02em] text-[#1a1a18] sm:text-[3rem] lg:text-[4.25rem] lg:leading-[1.02]">
{title}
</h1>
{subtitle ? (
<p className="m-0 mt-4 w-full max-w-[800px] self-center text-balance text-center font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.5] text-[#5a5550]">
{subtitle}
</p>
) : null}
</div>
</header>
<div className="mx-auto w-full max-w-[1710px] px-6 pb-20 pt-10 sm:px-10 sm:pt-12 lg:px-16 lg:pt-[42.5px] 2xl:px-[206px]">
{children}
</div>
</div>
);
}
export function AbiirFaqPageView({cms}: {cms: AbiirFaqPageCms}) {
const [openId, setOpenId] = useState<string | null>(null);
return (
<Shell
eyebrow="Support"
eyebrowTone="accent"
subtitle={cms.subtitle}
title={cms.title}
>
<div className="mx-auto flex w-full max-w-[800px] flex-col text-left">
{cms.sections.map((section, sIdx) => (
<section className={sIdx > 0 ? 'mt-[51px]' : undefined} key={sIdx}>
<h3 className="m-0 font-['Cormorant_Garamond',serif] text-[1.75rem] not-italic leading-tight text-[#1a1a18] [font-weight:350]">
{section.sectionTitle}
</h3>
<div className="mt-[25.5px] space-y-0 border-t border-[rgba(26,26,24,0.08)]">
{section.items.map((item, iIdx) => {
const id = `${sIdx}-${iIdx}`;
const isOpen = openId === id;
return (
<div
className="border-b border-[rgba(26,26,24,0.08)]"
key={id}
>
<button
aria-expanded={isOpen}
className="flex min-h-[64px] w-full cursor-pointer items-center justify-between gap-4 border-0 bg-transparent py-0 text-left"
onClick={() =>
setOpenId((p) => (p === id ? null : id))
}
type="button"
>
<span className="flex-1 py-5 pr-2 font-['Jost',sans-serif] text-[1.125rem] font-normal not-italic leading-[1.35] text-[#1a1a18]">
{item.question}
</span>
<ChevronDown
aria-hidden
className={`h-[15px] w-[15px] shrink-0 text-[#1a1a18] transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
strokeWidth={1.75}
/>
</button>
{isOpen ? (
<div className="pb-6 pt-0">
<p className="m-0 w-full max-w-full whitespace-pre-wrap font-['Raleway',sans-serif] text-[1rem] font-medium not-italic leading-[1.7] text-[#5a5550]">
{item.answer}
</p>
</div>
) : null}
</div>
);
})}
</div>
</section>
))}
</div>
</Shell>
);
}
const returnPolicyIcons = [RotateCcw, Package, Clock] as const;
const ordersShippingIcons = [Truck, Package, Clock] as const;
export function AbiirShippingPageView({cms}: {cms: AbiirShippingPageCms}) {
return (
<Shell
eyebrow="Help"
subtitle={cms.intro || undefined}
title={cms.title}
>
<div className="mx-auto w-full max-w-[800px] text-left">
<div className="flex flex-col gap-[25.5px] pt-0.5 sm:pt-2">
<h3 className="m-0 font-['Cormorant_Garamond',serif] text-[1.9rem] font-light not-italic leading-tight tracking-[-0.02em] text-[#1a1a18] sm:text-[1.9125rem]">
Orders & shipping
</h3>
<div className="flex flex-col gap-[25.5px]">
{cms.shippingOptions.map((row, i) => {
const Icon = ordersShippingIcons[i % ordersShippingIcons.length];
return (
<div
className="flex gap-4 bg-[#ede8e0] p-[25.5px] sm:gap-5"
key={`${row.title}-${i}`}
>
<div className="shrink-0 pt-0.5">
<Icon
aria-hidden
className="h-5 w-5 text-[#c17d5c]"
strokeWidth={1.5}
/>
</div>
<div className="abiir-shipping-qa-stack min-w-0 flex-1">
<h3 className="abiir-shipping-qa-title font-['Jost',sans-serif] text-[0.83rem] font-medium uppercase not-italic leading-tight tracking-[0.1em] text-[#1a1a18]">
{row.title}
</h3>
<div className="abiir-shipping-qa-subtitles">
<p className="font-['Jost',sans-serif] text-[0.765rem] font-medium not-italic leading-[1.4] text-[#c17d5c]">
{row.leadTime}
</p>
<p className="font-['Jost',sans-serif] text-[0.765rem] font-normal not-italic leading-[1.4] text-[#1a1a18]">
{row.priceLine}
</p>
</div>
<p className="abiir-shipping-qa-body whitespace-pre-line font-['Jost',sans-serif] text-[0.88rem] font-light not-italic leading-[1.75] text-[#7a7068]">
<LegalMailAndShippingLinks text={row.detail} />
</p>
</div>
</div>
);
})}
</div>
</div>
<div className="mt-[68px] flex flex-col gap-[25.5px]">
<h3 className="m-0 font-['Cormorant_Garamond',serif] text-[1.9rem] font-light not-italic leading-tight tracking-[-0.02em] text-[#1a1a18] sm:text-[1.9125rem]">
Returns & refunds
</h3>
<div className="flex flex-col gap-[25.5px]">
{cms.returnsPolicy.map((row, i) => {
const Icon = returnPolicyIcons[i % returnPolicyIcons.length];
return (
<div
className="flex gap-4 bg-[#ede8e0] p-[25.5px] sm:gap-5"
key={`${row.title}-${i}`}
>
<div className="shrink-0 pt-0.5">
<Icon
aria-hidden
className="h-5 w-5 text-[#c17d5c]"
strokeWidth={1.5}
/>
</div>
<div className="abiir-shipping-qa-stack min-w-0 flex-1">
<h3 className="abiir-shipping-qa-title font-['Jost',sans-serif] text-[0.83rem] font-medium uppercase not-italic leading-tight tracking-[0.1em] text-[#1a1a18]">
{row.title}
</h3>
<p className="abiir-shipping-qa-body whitespace-pre-line font-['Jost',sans-serif] text-[0.88rem] font-light not-italic leading-[1.75] text-[#7a7068]">
<LegalMailAndShippingLinks text={row.body} />
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
<div className="abiir-shipping-support-banner mt-[68px] -mx-6 bg-[#1a1a18] px-6 py-8 text-center sm:-mx-10 sm:px-10 sm:py-[34px] lg:-mx-16 lg:px-16 2xl:-mx-[206px] 2xl:px-[206px]">
<p className="m-0 font-['Cormorant_Garamond',serif] text-[1.17rem] font-light italic leading-snug text-[#f8f4ef]">
{cms.bannerHeadline}
</p>
<p className="m-0 mt-2 font-['Jost',sans-serif] text-[0.8rem] font-light not-italic leading-[1.8] text-[rgba(248,244,239,0.6)]">
<LegalMailAndShippingLinks
emailLinkClassName="abiir-shipping-support-banner-email transition-opacity hover:opacity-85"
text={`${cms.bannerTextBeforeEmail}${cms.supportEmail}${cms.bannerTextAfterEmail}`}
/>
</p>
</div>
</Shell>
);
}
export function AbiirInfoTextPageView({cms}: {cms: AbiirInfoPageCms}) {
return (
<Shell eyebrow="Help" title={cms.title}>
{cms.intro ? (
<p className="m-0 mb-8 w-full text-left font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.4] text-[#5a5550]">
{cms.intro}
</p>
) : null}
<div className="w-full space-y-6 text-left">
{cms.paragraphs.map((p, i) => (
<p
className="m-0 w-full max-w-full font-['Raleway',sans-serif] text-[1rem] font-medium leading-[1.8] text-[#5a5550]"
key={i}
>
{p}
</p>
))}
</div>
</Shell>
);
}
const EMAIL_IN_TEXT = /([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})/gi;
const SHIPPING_LINK_PHRASE = 'Shipping & Returns page';
function LegalMailAndShippingLinks({
text,
emailLinkClassName = 'text-[#c17d5c] no-underline transition-opacity hover:opacity-90',
}: {
text: string;
emailLinkClassName?: string;
}) {
if (text.includes(SHIPPING_LINK_PHRASE)) {
const [a, b] = text.split(SHIPPING_LINK_PHRASE);
return (
<>
{LegalMailAndShippingLinks({text: a || '', emailLinkClassName})}
<Link
className="text-[#c17d5c] no-underline transition-opacity hover:opacity-90"
to="/shipping"
>
{SHIPPING_LINK_PHRASE}
</Link>
{LegalMailAndShippingLinks({text: b || '', emailLinkClassName})}
</>
);
}
const parts = text.split(EMAIL_IN_TEXT);
return (
<>
{parts.map((part, i) =>
i % 2 === 1 ? (
<a
className={emailLinkClassName}
href={`mailto:${part}`}
key={`e-${i}-${part}`}
>
{part}
</a>
) : (
<span key={`t-${i}`}>{part}</span>
),
)}
</>
);
}
function LegalParagraphBlock({text}: {text: string}) {
if (text.includes('\n')) {
return (
<p className="m-0 whitespace-pre-line">
<LegalMailAndShippingLinks text={text} />
</p>
);
}
return (
<p className="m-0">
<LegalMailAndShippingLinks text={text} />
</p>
);
}
export function AbiirLegalPageView({cms}: {cms: AbiirLegalPageCms}) {
return (
<Shell
eyebrow={cms.heroEyebrow}
eyebrowTone="accent"
subtitle={cms.lastUpdatedLine}
title={cms.title}
>
<div className="mx-auto flex w-full max-w-[800px] flex-col gap-[42.5px] text-left">
<p className="m-0 font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.48] text-[#7a7068]">
<LegalMailAndShippingLinks text={cms.lead} />
</p>
<div className="flex w-full flex-col gap-10 sm:gap-[40px]">
{cms.sections.map((section, sIdx) => (
<section className="w-full" key={`${sIdx}-${section.title}`}>
<h3 className="m-0 [font-weight:350] font-['Cormorant_Garamond',serif] text-[1.75rem] not-italic leading-tight tracking-[-0.02em] text-[#1a1a18]">
{section.title}
</h3>
<div className="mt-4 space-y-3.5 sm:mt-[17px] sm:space-y-[12.75px]">
{section.paragraphs.map((p, pIdx) => (
<div
className="font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.48] text-[#7a7068]"
key={`${sIdx}-p-${pIdx}`}
>
<LegalParagraphBlock text={p} />
</div>
))}
{section.listItems?.length ? (
<ul className="m-0 list-outside list-disc space-y-2.5 pl-5 font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.48] text-[#7a7068] marker:text-[#7a7068]">
{section.listItems.map((item) => (
<li className="pl-1" key={item}>
<LegalMailAndShippingLinks text={item} />
</li>
))}
</ul>
) : null}
{section.trailingParagraphs?.map((p, tpIdx) => (
<div
className="font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.48] text-[#7a7068]"
key={`${sIdx}-t-${tpIdx}`}
>
<LegalParagraphBlock text={p} />
</div>
))}
</div>
</section>
))}
</div>
</div>
</Shell>
);
}
type ContactViewProps = {cms: AbiirContactPageCms};
function contactReachRowIcon(
row: AbiirContactReachRowCms,
) {
const c = 'h-4 w-4 text-[#c17d5c]';
if (row.icon === 'message') {
return <MessageCircle aria-hidden className={c} strokeWidth={1.5} />;
}
if (row.icon === 'clock') {
return <Clock aria-hidden className={c} strokeWidth={1.5} />;
}
return <Mail aria-hidden className={c} strokeWidth={1.5} />;
}
function socialIcon(network: 'instagram' | 'youtube') {
const c = 'h-[15px] w-[15px] shrink-0 text-[#1a1a18]';
return network === 'youtube' ? (
<Youtube aria-hidden className={c} strokeWidth={1.4} />
) : (
<Instagram aria-hidden className={c} strokeWidth={1.4} />
);
}
function ContactFormBlock({cms}: ContactViewProps) {
const fetcher = useFetcher<{ok?: boolean; error?: string}>();
const err = fetcher.data && !fetcher.data.ok;
const p = cms.formPlaceholders;
return (
<div className="flex w-full min-w-0 flex-col text-left">
{fetcher.data?.ok ? (
<p className="m-0 text-center font-['Raleway',sans-serif] text-[0.95rem] leading-[1.6] text-[#5a5550] sm:text-left">
{cms.formSuccessText}
</p>
) : (
<fetcher.Form
className={abiirFormStackClass}
method="post"
action="/api/contact"
>
{cms.formHeadline ? (
<h3 className="m-0 font-['Raleway',sans-serif] text-[0.8rem] font-semibold uppercase tracking-[0.1em] text-[#1a1a18]">
{cms.formHeadline}
</h3>
) : null}
<div className="flex flex-col gap-2">
<label className="flex flex-col gap-2" htmlFor="contact-name">
<span className={contactLabelClass}>Your name</span>
<input
className={contactInputClass}
id="contact-name"
name="name"
placeholder={p.name}
required
type="text"
/>
</label>
</div>
<div className="flex flex-col gap-2">
<label className="flex flex-col gap-2" htmlFor="contact-email">
<span className={contactLabelClass}>Email address</span>
<input
className={contactInputClass}
id="contact-email"
name="email"
placeholder={p.email}
required
type="email"
/>
</label>
</div>
<div className="flex flex-col gap-2">
<label className="flex flex-col gap-2" htmlFor="contact-subject">
<span className={contactLabelClass}>Subject</span>
<input
className={contactInputClass}
id="contact-subject"
name="subject"
placeholder={p.subject}
type="text"
/>
</label>
</div>
<div className="flex flex-col gap-2">
<label className="flex flex-col gap-2" htmlFor="contact-message">
<span className={contactLabelClass}>Message</span>
<textarea
className={`${contactInputClass} min-h-[166px] resize-y leading-[1.6]`}
id="contact-message"
name="message"
placeholder={p.message}
required
/>
</label>
</div>
{err ?
<p className={abiirFormErrorTextClass}>
Something went wrong. Please try again.
</p>
: null}
<button
className={abiirFormPrimaryButtonClass}
disabled={fetcher.state !== 'idle'}
style={abiirFormPrimaryButtonStyle}
type="submit"
>
{fetcher.state !== 'idle' ? 'Sending…' : cms.formSubmitLabel}
</button>
</fetcher.Form>
)}
</div>
);
}
export function AbiirContactPageView({cms}: ContactViewProps) {
return (
<Shell
eyebrow={cms.heroEyebrow}
eyebrowTone="accent"
subtitle={cms.intro}
title={cms.title}
>
<div className="mx-auto grid w-full max-w-[998px] grid-cols-1 items-start gap-[48px] text-left sm:gap-[68px] lg:grid-cols-2">
<div className="flex min-w-0 flex-col">
<h3 className="m-0 font-['Cormorant_Garamond',serif] text-[1.7rem] font-light not-italic leading-tight tracking-[-0.02em] text-[#1a1a18] sm:text-[1.9125rem]">
{cms.howToReachHeading}
</h3>
<div className="mt-8 flex flex-col gap-[25.5px] sm:mt-[4.25rem]">
{cms.reachRows.map((row) => {
const isEmail = row.icon === 'mail' || row.label.toLowerCase() === 'email';
return (
<div className="flex gap-[17px]" key={row.label}>
<div className="flex h-10 w-10 shrink-0 items-center justify-center bg-[#ede8e0]">
{contactReachRowIcon(row)}
</div>
<div className="min-w-0">
<p className="m-0 font-['Jost',sans-serif] text-[0.69rem] font-normal uppercase not-italic leading-tight tracking-[0.1em] text-[#7a7068]">
{row.label}
</p>
{isEmail ? (
<a
className="mt-1.5 block font-['Cormorant_Garamond',serif] text-[1.0625rem] font-medium not-italic text-[#1a1a18] no-underline transition-opacity hover:opacity-80"
href={`mailto:${cms.email}`}
>
{row.primary}
</a>
) : (
<p className="m-0 mt-1.5 font-['Cormorant_Garamond',serif] text-[1.0625rem] font-medium not-italic text-[#1a1a18]">
{row.primary}
</p>
)}
<p className="m-0 mt-0.5 font-['Jost',sans-serif] text-[0.765rem] font-light not-italic leading-[1.45] text-[#7a7068]">
{row.note}
</p>
</div>
</div>
);
})}
</div>
<div className="mt-10 border-t border-[rgba(26,26,24,0.08)] pt-10 sm:mt-11 sm:pt-11">
<p className="m-0 font-['Jost',sans-serif] text-[10.2px] font-medium uppercase not-italic leading-tight tracking-[0.2em] text-[#7a7068]">
{cms.followUsLabel}
</p>
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-[17px]">
{cms.socialLinks.map((s) => (
<a
className="inline-flex items-center gap-2.5 text-[#1a1a18] no-underline transition-opacity hover:opacity-75"
href={s.href}
key={s.label + s.network}
rel="noreferrer"
target="_blank"
>
{socialIcon(s.network)}
<span className="font-['Jost',sans-serif] text-[0.765rem] font-normal not-italic">
{s.label}
</span>
</a>
))}
</div>
</div>
</div>
{cms.showContactForm ? <ContactFormBlock cms={cms} /> : null}
</div>
</Shell>
);
}
function PressCard({
item,
onOpen,
}: {
item: AbiirPressItemCms;
onOpen: () => void;
}) {
return (
<button
className="group flex w-full cursor-pointer flex-col overflow-hidden border border-[rgba(26,26,24,0.08)] bg-white/50 text-left transition-opacity hover:opacity-92"
onClick={onOpen}
type="button"
>
<div
className="relative w-full"
style={{aspectRatio: '4/3', background: 'rgba(26,26,24,0.04)'}}
>
{item.image?.url ? (
<img
alt={item.image.alt || item.title}
className="h-full w-full object-cover"
height={item.image.height}
src={item.image.url}
width={item.image.width}
/>
) : (
<div
className="flex h-full w-full items-center justify-center font-['Cormorant_Garamond',serif] text-[2rem] font-light"
style={{color: accent}}
>
{item.title.charAt(0)}
</div>
)}
</div>
<div className="p-4">
<p
className="m-0 mb-1 font-['Jost',sans-serif] text-[0.7rem] uppercase tracking-wide"
style={{color: accent}}
>
{item.listCaption}
</p>
<p className="m-0 font-['Raleway',sans-serif] text-[0.95rem] font-semibold text-[#1a1a18] group-hover:underline">
{item.title}
</p>
</div>
</button>
);
}
export function AbiirPressPageView({cms}: {cms: AbiirPressPageCms}) {
const [open, setOpen] = useState<AbiirPressItemCms | null>(null);
return (
<Shell eyebrow="Press" title={cms.title}>
{cms.intro ? (
<p className="m-0 mb-10 w-full text-left font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.4] text-[#5a5550]">
{cms.intro}
</p>
) : null}
<div className="grid w-full grid-cols-1 gap-6 text-left sm:grid-cols-2 lg:grid-cols-3">
{cms.items.map((item, i) => (
<PressCard
item={item}
key={i}
onOpen={() => setOpen(item)}
/>
))}
</div>
{open ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{background: 'rgba(26,26,24,0.55)'}}
onClick={() => setOpen(null)}
onKeyDown={(e) => e.key === 'Escape' && setOpen(null)}
role="dialog"
aria-modal
tabIndex={-1}
>
<div
className="relative max-h-[90vh] w-full max-w-[640px] overflow-y-auto rounded-sm bg-white p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<button
aria-label="Close"
className="absolute right-3 top-3 border-none bg-transparent p-1 text-[#7a7068] hover:opacity-70"
onClick={() => setOpen(null)}
type="button"
>
<X size={22} />
</button>
{open.image?.url ? (
<div
className="relative -mx-2 mb-4"
style={{aspectRatio: '16/9'}}
>
<img
alt={open.image.alt || open.title}
className="h-full w-full object-cover"
src={open.image.url}
/>
</div>
) : null}
<h2 className="m-0 mb-2 pr-8 font-['Cormorant_Garamond',serif] text-[1.6rem] font-light text-[#1a1a18]">
{open.title}
</h2>
{open.listCaption ? (
<p className="m-0 mb-3 font-['Jost',sans-serif] text-[0.75rem] uppercase tracking-wide" style={{color: accent}}>
{open.listCaption}
</p>
) : null}
<p className="m-0 whitespace-pre-wrap font-['Raleway',sans-serif] text-[0.95rem] font-medium leading-[1.75] text-[#5a5550]">
{open.modalDescription}
</p>
</div>
</div>
) : null}
</Shell>
);
}

View File

@ -0,0 +1,463 @@
import {Image} from '@shopify/hydrogen';
import {motion} from 'motion/react';
import {Newsletter} from '~/components/Newsletter';
import type {AbiirAboutImageCms, AbiirAboutPageCms} from '~/lib/about';
import {abiirAboutImageIsRemote, MOCK_ABIIR_ABOUT_CMS} from '~/lib/about';
import {PAGE_TITLE_TYPOGRAPHY_CLASS} from '~/lib/pageTitle';
import type {NewsletterSectionCms} from '~/lib/newsletter';
/**
* GROQ: `aboutPage` singleton (`_id == "aboutPage"`).
* Use with `context.sanity.loadQuery(ABOUT_PAGE_GROQ)` or `useQuery` from `@sanity/react-loader`.
*/
export const ABOUT_PAGE_GROQ = `*[_type == "aboutPage" && _id == "aboutPage"][0]{
seo{ title, description },
heroEyebrow,
heroHeadlineBefore,
heroHeadlineItalic,
heroImage{
alt,
asset->{
_id,
url,
metadata{dimensions{width,height}}
}
},
beginningEyebrow,
beginningHeadlineBefore,
beginningHeadlineItalic,
beginningHeadlineAfter,
storyParagraphs,
beginningImage{
alt,
asset->{
_id,
url,
metadata{dimensions{width,height}}
}
},
beginningQuote,
beginningQuoteAttribution,
philosophyEyebrow,
philosophyHeadlineBefore,
philosophyHeadlineItalic,
philosophyBody,
philosophyImage{
alt,
asset->{
_id,
url,
metadata{dimensions{width,height}}
}
},
valuesTitle,
values[]{ title, description },
teamEyebrow,
teamTitle,
founderImage{
alt,
asset->{
_id,
url,
metadata{dimensions{width,height}}
}
},
founderRole,
founderName,
founderBio,
founderQuote,
quoteIcon{
alt,
asset->{
_id,
url,
metadata{dimensions{width,height}}
}
}
}` as const;
export {MOCK_ABIIR_ABOUT_CMS};
export type {AbiirAboutPageCms};
const ACCENT = '#c17d5c';
const CHARCOAL = '#1a1a18';
const MUTED = '#5a5550';
const MUTED_MID = '#7a7068';
const CREAM_TEXT = '#f8f4ef';
function AboutCmsImage({
image,
className,
sizes,
style,
}: {
image: AbiirAboutImageCms;
className?: string;
sizes: string;
style?: CSSProperties;
}) {
if (abiirAboutImageIsRemote(image)) {
const w = image.width ?? 1600;
const h = image.height ?? 1200;
return (
<Image
alt={image.alt}
className={className}
data={{
url: image.url,
altText: image.alt,
width: w,
height: h,
}}
loader={({src}) => src ?? ''}
sizes={sizes}
style={style}
/>
);
}
return (
<Image
alt={image.alt}
className={className}
height={image.height ?? 1200}
sizes={sizes}
src={image.url}
style={style}
width={image.width ?? 1600}
/>
);
}
export type AboutPageProps = {
cms: AbiirAboutPageCms;
newsletterCms: NewsletterSectionCms;
};
/**
* Our Story / About — layout from `React/src/app/pages/About.tsx`;
* main titles use `PAGE_TITLE_TYPOGRAPHY_CLASS` (same as homepage `Testimonials` heading).
* Pass `cms` from the route loader, or from `useQuery` with `ABOUT_PAGE_GROQ`.
*/
export function AboutPage({cms, newsletterCms}: AboutPageProps) {
/** Hero band height tracks banner aspect ratio (default `about_banner.png` 1710×495). */
const heroW = cms.heroImage.width ?? 1710;
const heroH = cms.heroImage.height ?? 495;
const heroBandStyle: CSSProperties = {
minHeight: `max(22rem, min(90svh, calc(100vw * ${heroH} / ${heroW})))`,
maxHeight: '90svh',
};
return (
<div className="min-h-screen bg-[#f8f4ef]">
{/* Hero — full-bleed banner + headline */}
<div className="relative w-full overflow-hidden">
<AboutCmsImage
className="absolute inset-0 size-full object-cover object-center"
image={cms.heroImage}
sizes="100vw"
/>
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(to bottom, rgba(26,26,24,0.35), rgba(26,26,24,0.45))',
}}
/>
<div
className="relative flex w-full flex-col items-center justify-center px-6 py-16 text-center sm:py-24"
style={heroBandStyle}
>
<motion.span
animate={{opacity: 1, y: 0}}
className="m-0 font-['Jost',sans-serif] text-[11.688px] font-medium uppercase leading-[17.531px] tracking-[2.9219px] not-italic"
initial={{opacity: 0, y: 16}}
style={{color: ACCENT}}
transition={{duration: 0.55}}
>
{cms.heroEyebrow}
</motion.span>
<motion.h1
animate={{opacity: 1, y: 0}}
className={`m-0 mt-[21.25px] max-w-[680px] ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
initial={{opacity: 0, y: 24}}
style={{color: CREAM_TEXT}}
transition={{duration: 0.65, delay: 0.08}}
>
{cms.heroHeadlineBefore}
<span className="font-['Cormorant_Garamond',serif] font-light italic">
{cms.heroHeadlineItalic}
</span>
</motion.h1>
</div>
</div>
{/* The Beginning — Figma 123:5133 */}
<section className="w-full bg-[#f8f4ef]">
<div className="mx-auto max-w-[1710px] px-6 py-16 sm:px-10 lg:px-16 lg:py-[137px] 2xl:px-[206px]">
<div className="mx-auto grid max-w-[1400px] items-start gap-12 lg:grid-cols-2 lg:gap-[85px]">
<motion.div
className="max-w-[649px]"
initial={{opacity: 0, y: 24}}
transition={{duration: 0.65}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
<p
className="m-0 mb-[21px] font-['Jost',sans-serif] text-[11.05px] font-medium uppercase leading-[16.575px] tracking-[2.7625px] not-italic"
style={{color: ACCENT}}
>
{cms.beginningEyebrow}
</p>
<h2
className={`m-0 mb-8 ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
style={{color: CHARCOAL}}
>
{cms.beginningHeadlineBefore}
<span className="font-['Cormorant_Garamond',serif] font-light italic">
{cms.beginningHeadlineItalic}
</span>
{cms.beginningHeadlineAfter}
</h2>
<div
className="flex flex-col gap-y-6 font-['Raleway',sans-serif] text-[16px] font-medium leading-[32px] not-italic"
style={{color: MUTED}}
>
{cms.storyParagraphs.map((p, i) => (
<p key={i} className="m-0">
{p}
</p>
))}
</div>
</motion.div>
<motion.div
className="relative w-full max-w-[649px] lg:justify-self-end"
initial={{opacity: 0, y: 24}}
transition={{duration: 0.7, delay: 0.05}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
<div className="abiir-about-beginning-image-wrap">
<AboutCmsImage
className="abiir-about-beginning-image block w-full h-auto"
image={cms.beginningImage}
sizes="(min-width: 1024px) 50vw, 100vw"
/>
</div>
<div
className="relative mt-6 box-border w-full max-w-[220px] bg-[#ede8e0] p-[21.25px] px-[25.5px] lg:absolute lg:bottom-[95px] lg:left-[-25.5px] lg:mt-0"
>
<p
className="m-0 font-['Cormorant_Garamond',serif] text-[17.85px] font-normal italic leading-[25.882px]"
style={{color: CHARCOAL}}
>
{cms.beginningQuote}
</p>
<p
className="m-0 mt-2 font-['Jost',sans-serif] text-[10.2px] font-light uppercase leading-[18.36px] tracking-[1.02px] not-italic"
style={{color: ACCENT}}
>
{cms.beginningQuoteAttribution}
</p>
</div>
</motion.div>
</div>
</div>
</section>
{/* Philosophy — Figma 123:5226 */}
<section className="relative min-h-[420px] w-full overflow-hidden">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<AboutCmsImage
className="absolute left-0 w-full max-w-none"
image={cms.philosophyImage}
sizes="100vw"
style={{height: '508.93%', top: '-226.67%'}}
/>
</div>
<div className="absolute inset-0 bg-[rgba(26,26,24,0.5)]" />
<div className="relative flex min-h-[420px] items-center justify-center px-6 py-16 lg:py-20">
<div className="mx-auto w-full min-w-0 max-w-[872px] text-center">
<motion.p
className="m-0 font-['Jost',sans-serif] text-[11.05px] font-medium uppercase leading-[16.575px] tracking-[3.094px] not-italic"
initial={{opacity: 0}}
style={{color: ACCENT}}
viewport={{once: true}}
whileInView={{opacity: 1}}
>
{cms.philosophyEyebrow}
</motion.p>
<motion.h2
className="m-0 mb-8 mt-[22px] whitespace-nowrap text-center font-['Cormorant_Garamond',serif] font-light tracking-[-0.969px] not-italic"
initial={{opacity: 0, y: 16}}
style={{
color: CREAM_TEXT,
fontSize: 'clamp(0.8125rem, 4.2vw, 48px)',
lineHeight: 1.12,
fontWeight: 300,
}}
transition={{delay: 0.06}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
{cms.philosophyHeadlineBefore}
<span className="font-['Cormorant_Garamond',serif] font-light italic">
{cms.philosophyHeadlineItalic}
</span>
</motion.h2>
<div className="mx-auto w-full max-w-[712px] px-[80px] box-border max-[520px]:px-6">
<motion.p
className="m-0 w-full text-center font-['Raleway',sans-serif] text-[16px] font-medium leading-[24px] not-italic"
initial={{opacity: 0, y: 12}}
style={{color: CREAM_TEXT}}
transition={{delay: 0.1}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
{cms.philosophyBody}
</motion.p>
</div>
</div>
</div>
</section>
{/* What We Stand For — Figma 123:5153 */}
<section className="w-full bg-[#ede8e0]">
<div className="mx-auto max-w-[1710px] px-6 pb-16 pt-16 sm:pb-20 sm:pt-20 lg:pb-24 lg:pt-[102px] sm:px-10 lg:px-16 2xl:px-[206px]">
<h2
className={`m-0 mb-10 text-center lg:mb-[59.5px] ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
style={{color: CHARCOAL}}
>
{cms.valuesTitle}
</h2>
<div
className="grid grid-cols-1 gap-px bg-[rgba(26,26,24,0.06)] sm:grid-cols-2 lg:grid-cols-4"
>
{cms.values.map((value, i) => (
<motion.div
key={value.title}
className="relative box-border min-h-[241px] bg-[#ede8e0] px-[34px] pb-10 pt-[42.5px]"
initial={{opacity: 0, y: 16}}
transition={{duration: 0.5, delay: i * 0.06}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
<span
className="block font-['Cormorant_Garamond',serif] text-[51px] font-light leading-[51px] not-italic"
style={{color: 'rgba(26,26,24,0.28)'}}
>
{String(i + 1).padStart(2, '0')}
</span>
<h3
className="m-0 mt-5 font-['Cormorant_Garamond',serif] text-[24px] font-medium leading-[25.5px] tracking-[-0.204px] not-italic"
style={{color: CHARCOAL}}
>
{value.title}
</h3>
<p
className="m-0 mt-3 max-w-[243px] font-['Raleway',sans-serif] text-[16px] font-medium leading-[23.698px] not-italic"
style={{color: MUTED_MID}}
>
{value.description}
</p>
</motion.div>
))}
</div>
</div>
</section>
{/* Meet the Team — Figma 123:5185 */}
<section className="w-full bg-[#f8f4ef]">
<div className="mx-auto max-w-[1710px] px-6 py-16 sm:px-10 sm:py-20 lg:px-16 lg:py-[94px] 2xl:px-[206px]">
<div className="mx-auto flex max-w-[1400px] flex-col gap-10 lg:gap-[68px]">
<div className="flex w-full flex-col items-center gap-[17px] text-center">
<p
className="m-0 w-full font-['Jost',sans-serif] text-[11.05px] font-medium uppercase leading-[16.575px] tracking-[2.7625px] not-italic"
style={{color: ACCENT}}
>
{cms.teamEyebrow}
</p>
<h2
className={`m-0 ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
style={{color: CHARCOAL}}
>
{cms.teamTitle}
</h2>
</div>
<div className="flex flex-col items-start justify-center gap-12 lg:flex-row lg:gap-[59px]">
<motion.div
className="mx-auto w-full max-w-[427px] shrink-0 overflow-hidden lg:mx-0"
initial={{opacity: 0, y: 20}}
style={{maxHeight: '640px'}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
<AboutCmsImage
className="size-full object-cover"
image={cms.founderImage}
sizes="(min-width: 1024px) 427px, 100vw"
style={{minHeight: '480px'}}
/>
</motion.div>
<motion.div
className="flex w-full max-w-[816px] flex-col gap-10 lg:gap-[69px]"
initial={{opacity: 0, y: 20}}
transition={{delay: 0.08}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
<div className="flex flex-col gap-2.5">
<p
className="m-0 font-['Jost',sans-serif] text-[10.2px] font-medium uppercase leading-[18.36px] tracking-[1.836px] not-italic"
style={{color: ACCENT}}
>
{cms.founderRole}
</p>
<h3
className="m-0 font-['Cormorant_Garamond',serif] text-[32px] font-normal leading-normal tracking-[-0.2465px] not-italic"
style={{color: CHARCOAL}}
>
{cms.founderName}
</h3>
<div
className="mt-2 flex flex-col gap-4 font-['Raleway',sans-serif] text-[16px] font-medium leading-[24px] not-italic"
style={{color: MUTED}}
>
{cms.founderBio.map((para, i) => (
<p key={i} className="m-0">
{para}
</p>
))}
</div>
</div>
<div
className="box-border flex w-full flex-col items-center gap-7 bg-[#f1e6d9] p-8 text-center"
>
<span aria-hidden className="inline-flex size-8 shrink-0">
<AboutCmsImage
className="size-8"
image={cms.quoteIcon}
sizes="32px"
/>
</span>
<p
className="m-0 max-w-[708px] font-['Cormorant_Garamond',serif] text-[22.95px] font-normal italic leading-[36.72px]"
style={{color: CHARCOAL}}
>
{cms.founderQuote}
</p>
</div>
</motion.div>
</div>
</div>
</div>
</section>
<Newsletter cms={newsletterCms} />
</div>
);
}

View File

@ -0,0 +1,77 @@
import {Form, NavLink} from 'react-router';
import {
LayoutGrid,
LogOut,
MapPin,
Package,
UserRound,
} from 'lucide-react';
const linkBase =
"relative flex min-h-[54px] w-full items-center gap-3 border-b border-[rgba(0,0,0,0)] pl-3 pr-3 font-['Raleway',sans-serif] text-[0.86rem] uppercase tracking-[0.1em] no-underline transition-colors";
const inactive =
"border-l-[3px] border-transparent font-normal text-[#5a5550] hover:bg-[#faf8f3]/60";
const active =
"border-l-[3px] border-[#5d0b24] bg-[#faf8f3] font-medium text-[#5d0b24]";
type NavItem = {to: string; label: string; end?: boolean; Icon: typeof Package};
const ITEMS: NavItem[] = [
{to: '/account', label: 'Overview', end: true, Icon: LayoutGrid},
{to: '/account/orders', label: 'Orders', Icon: Package},
{to: '/account/profile', label: 'Personal Info', Icon: UserRound},
{to: '/account/addresses', label: 'Addresses', Icon: MapPin},
];
/**
* Account area left nav — Figma 123:11834. Only links for implemented routes
* (no Preferences / Security).
*/
export function AccountNav() {
return (
<nav
aria-label="Account"
className="w-full max-w-full shrink-0 border border-[rgba(26,26,26,0.07)] bg-white lg:max-w-[240px] lg:justify-self-start"
>
<div className="flex flex-col">
{ITEMS.map(({to, label, end, Icon}) => (
<NavLink
className={({isActive}) =>
`${linkBase} ${isActive ? active : inactive}`
}
end={end}
key={to}
to={to}
>
<Icon
aria-hidden
className="size-4 shrink-0 text-[currentColor] opacity-90"
strokeWidth={1.5}
/>
<span>{label}</span>
</NavLink>
))}
<Form
action="/account/logout"
className="border-t border-[rgba(0,0,0,0)]"
method="POST"
>
<button
className="flex min-h-[54px] w-full cursor-pointer items-center gap-3 border-0 border-l-[3px] border-transparent bg-transparent pl-3 pr-3 text-left font-['Raleway',sans-serif] text-[0.86rem] font-normal uppercase tracking-[0.1em] text-[#b03a2e] transition-opacity hover:opacity-85"
type="submit"
>
<LogOut
aria-hidden
className="size-4 shrink-0"
strokeWidth={1.5}
/>
<span>Sign out</span>
</button>
</Form>
</div>
</nav>
);
}

View File

@ -0,0 +1,189 @@
import {Link} from 'react-router';
import {Money} from '@shopify/hydrogen';
import type {CustomerFragment} from 'customer-accountapi.generated';
import {AccountWishlistStat} from '~/components/AccountWishlistStat';
import {
getCustomerDisplayName,
getCustomerInitials,
} from '~/lib/customerDisplay';
export type AccountOverviewOrder = {
id: string;
name: string;
processedAt: string | null;
fulfillmentStatus: string | null;
totalPrice: {
amount: string;
currencyCode: string;
} | null;
lineItems: {
nodes: Array<{
title: string;
quantity: number;
price: {
amount: string;
currencyCode: string;
} | null;
}>;
} | null;
};
export type AccountOverviewData = {
creationDate: string | null;
orders: {
pageInfo: {hasNextPage: boolean};
nodes: AccountOverviewOrder[];
} | null;
};
function sumTotalSpent(orders: AccountOverviewOrder[], currency: string) {
let total = 0;
for (const o of orders) {
if (o.totalPrice && o.totalPrice.currencyCode === currency) {
total += Number.parseFloat(o.totalPrice.amount) || 0;
}
}
return total;
}
type Props = {
customer: CustomerFragment;
overview: AccountOverviewData;
};
/**
* Figma 123:11834 account overview: profile band, stat tiles, recent order.
*/
export function AccountOverview({customer, overview}: Props) {
const orders = overview.orders?.nodes ?? [];
const hasNext = overview.orders?.pageInfo.hasNextPage ?? false;
const count = orders.length;
const orderCountLabel = hasNext ? `${count}+` : String(count);
const spendCurrency = orders.find((o) => o.totalPrice)?.totalPrice
?.currencyCode;
const recent = orders[0] ?? null;
const memberLabel =
overview.creationDate ?
`Member since ${new Date(overview.creationDate).getFullYear()}`
: 'Member';
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-4 rounded-sm bg-[#5d0b24] px-5 py-5 text-white">
<div
aria-hidden
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full border border-white/30 bg-white/10 font-['Raleway',sans-serif] text-lg font-medium uppercase tracking-wide"
>
{getCustomerInitials(customer)}
</div>
<div className="min-w-0">
<p className="font-['Raleway',sans-serif] text-base font-medium tracking-tight sm:text-lg">
{getCustomerDisplayName(customer) || 'Account'}
</p>
<p className="mt-0.5 font-['Raleway',sans-serif] text-sm text-white/85">
{memberLabel}
</p>
</div>
</div>
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
<AccountWishlistStat />
<li className="border border-[rgba(26,26,26,0.1)] bg-[#faf8f3] px-4 py-4 text-center sm:text-left">
<p className="font-['Raleway',sans-serif] text-xs uppercase tracking-[0.12em] text-[#5a5550]">
Orders
</p>
<p className="mt-1 font-['Raleway',sans-serif] text-xl font-medium text-[#1a1a18]">
{orderCountLabel}
</p>
</li>
<li className="border border-[rgba(26,26,26,0.1)] bg-[#faf8f3] px-4 py-4 text-center sm:text-left">
<p className="font-['Raleway',sans-serif] text-xs uppercase tracking-[0.12em] text-[#5a5550]">
Total spent
</p>
<div className="mt-1 font-['Raleway',sans-serif] text-xl font-medium text-[#1a1a18]">
{orders.length > 0 && spendCurrency ?
<Money
data={{
amount: String(sumTotalSpent(orders, spendCurrency)),
currencyCode: spendCurrency,
}}
/>
: '—'}
</div>
</li>
</ul>
<section className="border border-[rgba(26,26,26,0.1)] bg-white" aria-label="Recent order">
<div className=" px-4 pt-2 pb-3">
<h2 className="m-0 font-['Cormorant_Garamond',serif] text-[29.75px] font-light not-italic leading-[33.32px] tracking-[-0.446px] text-[#1A1A1A]">
Recent order
</h2>
</div>
{recent ?
<div className="px-4 py-4">
<div className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between">
<p className="font-['Raleway',sans-serif] text-base font-medium text-[#1a1a18]">
{recent.name}
</p>
<p className="font-['Raleway',sans-serif] text-sm text-[#5a5550]">
{recent.processedAt ?
new Date(recent.processedAt).toLocaleDateString()
: '—'}
</p>
</div>
{recent.fulfillmentStatus ?
<p className="mt-1 font-['Raleway',sans-serif] text-sm text-[#5a5550]">
{recent.fulfillmentStatus}
</p>
: null}
{recent.lineItems?.nodes.length ?
<ul className="mt-3 space-y-1 border-t border-[rgba(26,26,26,0.06)] pt-3">
{recent.lineItems.nodes.map((line, i) => (
<li
className="flex flex-wrap items-baseline justify-between gap-2 text-sm"
key={`${line.title}-${i}`}
>
<span className="font-['Raleway',sans-serif] text-[#1a1a18]">
{line.title}{' '}
<span className="text-[#5a5550]">×{line.quantity}</span>
</span>
{line.price ?
<span className="font-['Raleway',sans-serif] text-[#1a1a18]">
<Money data={line.price} />
</span>
: null}
</li>
))}
</ul>
: null}
{recent.totalPrice ?
<div className="mt-3 flex justify-end border-t border-[rgba(26,26,26,0.06)] pt-3 font-['Raleway',sans-serif] text-sm text-[#1a1a18]">
Total:{' '}
<span className="ml-1">
<Money data={recent.totalPrice} />
</span>
</div>
: null}
<p className="mt-4">
<Link
className="font-['Raleway',sans-serif] text-sm font-medium text-[#5d0b24] no-underline hover:opacity-80"
to={`/account/orders/${btoa(recent.id)}`}
>
View order
</Link>
</p>
</div>
: <p className="px-4 pt-10 pb-8 text-center font-['Raleway',sans-serif] text-sm text-[#5a5550]">
You haven&rsquo;t placed an order yet.{' '}
<Link className="text-[#5d0b24] no-underline hover:opacity-80" to="/collections/all">
Browse the shop
</Link>
</p>
}
</section>
</div>
);
}

View File

@ -0,0 +1,29 @@
import {Link} from 'react-router';
import {useAbiirWishlistCount} from '~/hooks/useAbiirWishlistCount';
/**
* Account overview tile: localStorage wishlist count (client), links to /wishlist.
*/
export function AccountWishlistStat() {
const n = useAbiirWishlistCount();
return (
<li className="border border-[rgba(26,26,26,0.1)] bg-[#faf8f3] px-4 py-4 text-center sm:text-left">
<Link
className="block text-inherit no-underline transition-opacity hover:opacity-85"
to="/wishlist"
>
<p className="m-0 font-['Raleway',sans-serif] text-xs uppercase tracking-[0.12em] text-[#5a5550]">
Wishlist
</p>
<p className="mt-1 font-['Raleway',sans-serif] text-xl font-medium text-[#1a1a18]">
{n > 0 ? n : '—'}
</p>
{n > 0 ?
<p className="m-0 mt-1 font-['Raleway',sans-serif] text-xs font-medium text-[#5d0b24]">
View list
</p>
: null}
</Link>
</li>
);
}

View File

@ -0,0 +1,37 @@
import {type FetcherWithComponents} from 'react-router';
import {CartForm, type OptimisticCartLineInput} from '@shopify/hydrogen';
export function AddToCartButton({
analytics,
children,
disabled,
lines,
onClick,
}: {
analytics?: unknown;
children: React.ReactNode;
disabled?: boolean;
lines: Array<OptimisticCartLineInput>;
onClick?: () => void;
}) {
return (
<CartForm route="/cart" inputs={{lines}} action={CartForm.ACTIONS.LinesAdd}>
{(fetcher: FetcherWithComponents<any>) => (
<>
<input
name="analytics"
type="hidden"
value={JSON.stringify(analytics)}
/>
<button
type="submit"
onClick={onClick}
disabled={disabled ?? fetcher.state !== 'idle'}
>
{children}
</button>
</>
)}
</CartForm>
);
}

View File

@ -0,0 +1,101 @@
import {
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from 'react';
import {useId} from 'react';
type AsideType = 'search' | 'cart' | 'mobile' | 'closed';
type AsideContextValue = {
type: AsideType;
open: (mode: AsideType) => void;
close: () => void;
};
/**
* A side bar component with Overlay
* @example
* ```jsx
* <Aside type="search" heading="SEARCH">
* <input type="search" />
* ...
* </Aside>
* ```
*/
export function Aside({
children,
heading,
type,
}: {
children?: React.ReactNode;
type: AsideType;
heading: React.ReactNode;
}) {
const {type: activeType, close} = useAside();
const expanded = type === activeType;
const id = useId();
useEffect(() => {
const abortController = new AbortController();
if (expanded) {
document.addEventListener(
'keydown',
function handler(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
},
{signal: abortController.signal},
);
}
return () => abortController.abort();
}, [close, expanded]);
return (
<div
aria-modal
className={`overlay ${expanded ? 'expanded' : ''}`}
role="dialog"
aria-labelledby={id}
>
<button className="close-outside" onClick={close} />
<aside data-aside-type={type}>
<header>
<h3 id={id}>{heading}</h3>
<button className="close reset" onClick={close} aria-label="Close">
&times;
</button>
</header>
<main>{children}</main>
</aside>
</div>
);
}
const AsideContext = createContext<AsideContextValue | null>(null);
Aside.Provider = function AsideProvider({children}: {children: ReactNode}) {
const [type, setType] = useState<AsideType>('closed');
return (
<AsideContext.Provider
value={{
type,
open: setType,
close: () => setType('closed'),
}}
>
{children}
</AsideContext.Provider>
);
};
export function useAside() {
const aside = useContext(AsideContext);
if (!aside) {
throw new Error('useAside must be used within an AsideProvider');
}
return aside;
}

View File

@ -0,0 +1,127 @@
import {Image} from '@shopify/hydrogen';
import {AbiirEyebrow} from '~/components/AbiirEyebrow';
import {PAGE_TITLE_TYPOGRAPHY_CLASS} from '~/lib/pageTitle';
import type {BrandValueIconKey, BrandValuesCms} from '~/lib/brandValues';
import {
BRAND_VALUES_GROQ,
MOCK_BRAND_VALUES_CMS,
} from '~/lib/brandValues';
import iconFlaskSrc from '~/assets/brand-values/icon-flask.svg';
import iconLeafSrc from '~/assets/brand-values/icon-leaf.svg';
import iconShieldSrc from '~/assets/brand-values/icon-shield.svg';
import iconSparklesSrc from '~/assets/brand-values/icon-sparkles.svg';
/**
* Sanity GROQ for this module (loader + future `useQuery`):
* @see {@link BRAND_VALUES_GROQ}
*/
export {BRAND_VALUES_GROQ, MOCK_BRAND_VALUES_CMS};
export type {BrandValuesCms, BrandValueIconKey} from '~/lib/brandValues';
/** Figma 32:7186 — raster icons exported as SVG from MCP asset URLs. */
const ICON_SRC: Record<BrandValueIconKey, string> = {
leaf: iconLeafSrc,
flaskConical: iconFlaskSrc,
shield: iconShieldSrc,
sparkles: iconSparklesSrc,
};
/**
* Figma 32:7200–32:7230 — description block horizontal padding + max width per column.
*/
const DESCRIPTION_LAYOUT = [
{wrapClass: 'px-[5px]', textClass: 'max-w-[228px]'},
{wrapClass: 'px-[9px]', textClass: 'max-w-[220px]'},
{wrapClass: '', textClass: 'max-w-[238px]'},
{wrapClass: 'px-[16px]', textClass: 'max-w-[206px]'},
] as const;
export type BrandValuesProps = {
cms: BrandValuesCms;
};
export function BrandValues({cms}: BrandValuesProps) {
return (
<section
className="w-full !py-0"
style={{background: cms.sectionBackground}}
>
{/* Figma: px-[206px] py-[94px] gap-[68px] within1710 frame */}
<div className="mx-auto flex w-full max-w-[1710px] flex-col gap-6 px-6 py-16 sm:px-10 sm:py-20 lg:gap-[68px] lg:px-[206px] lg:py-[94px]">
<header className="mx-auto w-full max-w-[1298px]">
{/* Flow layout so type size isn’t fighting absolute + min-height (which only added empty gap). */}
<div className="flex flex-col items-center gap-3 text-center lg:gap-8">
<AbiirEyebrow className="mb-0" surface="light" textCase="preserve">
{cms.eyebrow}
</AbiirEyebrow>
<h2
className={`m-0 max-w-[1298px] text-center ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
style={{color: cms.titleColor, marginBottom: '-40px'}}
>
{cms.headlinePrefix}
<span
className="font-['Cormorant_Garamond',serif] font-light italic"
style={{fontWeight: 300}}
>
{cms.headlineItalic}
</span>
</h2>
</div>
</header>
<div
className="grid w-full grid-cols-1 gap-px sm:grid-cols-2 lg:grid-cols-4 lg:grid-rows-1"
style={{background: cms.gridGutterColor}}
>
{cms.values.map((value, index) => {
const layout =
index < DESCRIPTION_LAYOUT.length
? DESCRIPTION_LAYOUT[index]
: {wrapClass: '', textClass: 'max-w-[238px]'};
return (
<div
key={`${value.iconKey}-${value.title}`}
className="relative min-h-0 py-10 sm:min-h-[200px] lg:h-[269.688px] lg:py-0"
style={{background: cms.sectionBackground}}
>
<div className="flex flex-col items-center gap-[16px] px-4 text-center sm:px-6 lg:absolute lg:left-1/2 lg:top-[42.5px] lg:w-[238px] lg:-translate-x-1/2 lg:px-0">
<div
className="flex size-[52px] shrink-0 items-center justify-center px-[16px]"
style={{background: cms.iconTileBackground}}
>
<Image
alt=""
aria-hidden
className="size-[20px] object-contain object-center"
height={20}
src={ICON_SRC[value.iconKey]}
width={20}
/>
</div>
<div className="flex h-[23.375px] w-full items-center justify-center">
<h3
className="m-0 whitespace-nowrap font-['Cormorant_Garamond',serif] text-[24px] font-medium leading-[23.375px] tracking-[-0.187px]"
style={{color: cms.titleColor}}
>
{value.title}
</h3>
</div>
<div
className={`flex w-full items-center justify-center ${layout.wrapClass}`}
>
<p
className={`m-0 w-full font-['Raleway',sans-serif] text-[16px] font-medium leading-[23.698px] ${layout.textClass}`}
style={{color: cms.bodyColor}}
>
{value.description}
</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,56 @@
import {Money} from '@shopify/hydrogen';
import type {MoneyV2} from '@shopify/hydrogen/storefront-api-types';
/** Free shipping threshold in major currency units (e.g. 75 = $75). Match store promos. */
const FREE_SHIPPING_THRESHOLD_MAJOR = 75;
type Props = {
subtotal?: MoneyV2 | null;
};
/**
* Figma cart drawer (node 123:13655): “Add $X more for free shipping” + progress bar.
*/
export function CartFreeShippingBar({subtotal}: Props) {
if (!subtotal?.amount || !subtotal.currencyCode) return null;
const spent = Number.parseFloat(subtotal.amount);
if (!Number.isFinite(spent) || spent < 0) return null;
const threshold = FREE_SHIPPING_THRESHOLD_MAJOR;
const pct = Math.min(100, Math.max(0, (spent / threshold) * 100));
const remaining = Math.max(0, threshold - spent);
if (remaining <= 0) {
return (
<div className="cart-free-ship cart-free-ship--met">
<p className="cart-free-ship__text">
You qualify for <strong>free shipping</strong>
</p>
<div className="cart-free-ship__track">
<div className="cart-free-ship__fill" style={{width: '100%'}} />
</div>
</div>
);
}
return (
<div className="cart-free-ship">
<p className="cart-free-ship__text">
Add{' '}
<span className="cart-free-ship__accent">
<Money
data={{
amount: remaining.toFixed(2),
currencyCode: subtotal.currencyCode,
}}
/>
</span>{' '}
more for free shipping
</p>
<div className="cart-free-ship__track">
<div className="cart-free-ship__fill" style={{width: `${pct}%`}} />
</div>
</div>
);
}

View File

@ -0,0 +1,298 @@
import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types';
import type {CartLayout, LineItemChildrenMap} from '~/components/CartMain';
import {CartForm, Image, type OptimisticCartLine} from '@shopify/hydrogen';
import {useVariantUrl} from '~/lib/variants';
import {Link} from 'react-router';
import {ProductPrice} from './ProductPrice';
import {useAside} from './Aside';
import type {
CartApiQueryFragment,
CartLineFragment,
} from 'storefrontapi.generated';
export type CartLine = OptimisticCartLine<CartApiQueryFragment>;
function optionSummary(selectedOptions: CartLineFragment['merchandise']['selectedOptions']) {
if (!selectedOptions?.length) return null;
const values = selectedOptions
.map((o) => (o.value ?? '').trim())
.filter(Boolean)
.filter((v) => v.toLowerCase() !== 'default title');
return values.length ? values.join(' · ') : null;
}
/**
* Trash icon — Figma cart line remove (node 123:13655).
*/
function TrashIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden xmlns="http://www.w3.org/2000/svg">
<path
d="M9 3h6l1 2h5v2H3V5h5l1-2Zm1 6h2v9h-2V9Zm4 0h2v9h-2V9ZM5 9h2v9H5V9Z"
fill="currentColor"
/>
</svg>
);
}
export function CartLineItem({
layout,
line,
childrenMap,
}: {
layout: CartLayout;
line: CartLine;
childrenMap: LineItemChildrenMap;
}) {
const {id, merchandise} = line;
const {product, title, image, selectedOptions} = merchandise;
const lineItemUrl = useVariantUrl(product.handle, selectedOptions);
const {close} = useAside();
const lineItemChildren = childrenMap[id];
const childrenLabelId = `cart-line-children-${id}`;
const vendorEyebrow = product.vendor?.trim();
const variantLine = optionSummary(selectedOptions);
if (layout === 'aside') {
return (
<li key={id} className="cart-line cart-line--abiir-aside">
<div className="cart-line-abiir">
{image ?
<Link
to={lineItemUrl}
prefetch="intent"
onClick={close}
className="cart-line-abiir__img-wrap"
>
<Image
alt={title}
aspectRatio="4/5"
data={image}
height={100}
loading="lazy"
width={80}
/>
</Link>
: null}
<div className="cart-line-abiir__body">
<div className="cart-line-abiir__top">
<div className="cart-line-abiir__text">
{vendorEyebrow ?
<p className="cart-line-abiir__vendor">{vendorEyebrow}</p>
: null}
<Link
to={lineItemUrl}
prefetch="intent"
onClick={close}
className="cart-line-abiir__title-link"
>
<p className="cart-line-abiir__title">{product.title}</p>
</Link>
{variantLine ?
<p className="cart-line-abiir__meta">{variantLine}</p>
: null}
</div>
<CartLineRemoveButton lineIds={[id]} disabled={!!line.isOptimistic} icon />
</div>
<div className="cart-line-abiir__row">
<CartLineQuantity line={line} layout={layout} />
<div className="cart-line-abiir__price">
<ProductPrice price={line?.cost?.totalAmount} />
</div>
</div>
</div>
</div>
{lineItemChildren ?
<div>
<p id={childrenLabelId} className="sr-only">
Line items with {product.title}
</p>
<ul aria-labelledby={childrenLabelId} className="cart-line-children">
{lineItemChildren.map((childLine) => (
<CartLineItem
childrenMap={childrenMap}
key={childLine.id}
line={childLine}
layout={layout}
/>
))}
</ul>
</div>
: null}
</li>
);
}
return (
<li key={id} className="cart-line">
<div className="cart-line-inner">
{image && (
<Image
alt={title}
aspectRatio="1/1"
data={image}
height={100}
loading="lazy"
width={100}
/>
)}
<div>
<Link
prefetch="intent"
to={lineItemUrl}
onClick={() => {
if (layout === 'aside') {
close();
}
}}
>
<p>
<strong>{product.title}</strong>
</p>
</Link>
<ProductPrice price={line?.cost?.totalAmount} />
<ul>
{selectedOptions
.filter(
(option) =>
(option.value ?? '').trim().toLowerCase() !== 'default title',
)
.map((option) => (
<li key={option.name}>
<small>
{option.name}: {option.value}
</small>
</li>
))}
</ul>
<CartLineQuantity line={line} layout={layout} />
</div>
</div>
{lineItemChildren ? (
<div>
<p id={childrenLabelId} className="sr-only">
Line items with {product.title}
</p>
<ul aria-labelledby={childrenLabelId} className="cart-line-children">
{lineItemChildren.map((childLine) => (
<CartLineItem
childrenMap={childrenMap}
key={childLine.id}
line={childLine}
layout={layout}
/>
))}
</ul>
</div>
) : null}
</li>
);
}
function CartLineQuantity({line, layout}: {line: CartLine; layout: CartLayout}) {
if (!line || typeof line?.quantity === 'undefined') return null;
const {id: lineId, quantity, isOptimistic} = line;
const prevQuantity = Number(Math.max(0, quantity - 1).toFixed(0));
const nextQuantity = Number((quantity + 1).toFixed(0));
const wrapClass =
layout === 'aside' ? 'cart-line-qty-abiir' : 'cart-line-quantity';
return (
<div className={wrapClass}>
{layout !== 'aside' ?
<small>Quantity: {quantity} &nbsp;&nbsp;</small>
: null}
<CartLineUpdateButton lines={[{id: lineId, quantity: prevQuantity}]}>
<button
aria-label="Decrease quantity"
disabled={quantity <= 1 || !!isOptimistic}
name="decrease-quantity"
value={prevQuantity}
type="submit"
>
<span>&#8722;</span>
</button>
</CartLineUpdateButton>
{layout === 'aside' ?
<span className="cart-line-qty-abiir__value">{quantity}</span>
: (
<>
&nbsp;
</>
)}
<CartLineUpdateButton lines={[{id: lineId, quantity: nextQuantity}]}>
<button
aria-label="Increase quantity"
name="increase-quantity"
value={nextQuantity}
disabled={!!isOptimistic}
type="submit"
>
<span>&#43;</span>
</button>
</CartLineUpdateButton>
{layout !== 'aside' ?
<>
&nbsp;
<CartLineRemoveButton lineIds={[lineId]} disabled={!!isOptimistic} />
</>
: null}
</div>
);
}
function CartLineRemoveButton({
lineIds,
disabled,
icon = false,
}: {
lineIds: string[];
disabled: boolean;
icon?: boolean;
}) {
return (
<CartForm
fetcherKey={[CartForm.ACTIONS.LinesRemove, ...lineIds].join('-')}
route="/cart"
action={CartForm.ACTIONS.LinesRemove}
inputs={{lineIds}}
>
<button
disabled={disabled}
type="submit"
className={icon ? 'cart-line-abiir__remove' : undefined}
aria-label="Remove line item"
>
{icon ? <TrashIcon /> : 'Remove'}
</button>
</CartForm>
);
}
function CartLineUpdateButton({
children,
lines,
}: {
children: React.ReactNode;
lines: CartLineUpdateInput[];
}) {
const lineIds = lines.map((line) => line.id);
return (
<CartForm
fetcherKey={getUpdateKey(lineIds)}
route="/cart"
action={CartForm.ACTIONS.LinesUpdate}
inputs={{lines}}
>
{children}
</CartForm>
);
}
function getUpdateKey(lineIds: string[]) {
return [CartForm.ACTIONS.LinesUpdate, ...lineIds].join('-');
}

View File

@ -0,0 +1,125 @@
import {useOptimisticCart} from '@shopify/hydrogen';
import {Link} from 'react-router';
import type {CartApiQueryFragment} from 'storefrontapi.generated';
import {useAside} from '~/components/Aside';
import {CartFreeShippingBar} from '~/components/CartFreeShippingBar';
import {CartLineItem, type CartLine} from '~/components/CartLineItem';
import {CartSummary} from './CartSummary';
export type CartLayout = 'page' | 'aside';
export type CartMainProps = {
cart: CartApiQueryFragment | null;
layout: CartLayout;
};
export type LineItemChildrenMap = {[parentId: string]: CartLine[]};
/** Returns a map of all line items and their children. */
function getLineItemChildrenMap(lines: CartLine[]): LineItemChildrenMap {
const children: LineItemChildrenMap = {};
for (const line of lines) {
if ('parentRelationship' in line && line.parentRelationship?.parent) {
const parentId = line.parentRelationship.parent.id;
if (!children[parentId]) children[parentId] = [];
children[parentId].push(line);
}
if ('lineComponents' in line) {
const children = getLineItemChildrenMap(line.lineComponents);
for (const [parentId, childIds] of Object.entries(children)) {
if (!children[parentId]) children[parentId] = [];
children[parentId].push(...childIds);
}
}
}
return children;
}
/**
* The main cart component that displays the cart items and summary.
* It is used by both the /cart route and the cart aside dialog.
*/
export function CartMain({layout, cart: originalCart}: CartMainProps) {
// The useOptimisticCart hook applies pending actions to the cart
// so the user immediately sees feedback when they modify the cart.
const cart = useOptimisticCart(originalCart);
const linesCount = Boolean(cart?.lines?.nodes?.length || 0);
const withDiscount =
cart &&
Boolean(cart?.discountCodes?.filter((code) => code.applicable)?.length);
const className = [
'cart-main',
withDiscount ? 'with-discount' : '',
layout === 'aside' ? 'cart-main--abiir-aside' : '',
]
.filter(Boolean)
.join(' ');
const cartHasItems = cart?.totalQuantity ? cart.totalQuantity > 0 : false;
const childrenMap = getLineItemChildrenMap(cart?.lines?.nodes ?? []);
return (
<section
className={className}
aria-label={layout === 'page' ? 'Cart page' : 'Cart drawer'}
>
<CartEmpty hidden={linesCount} layout={layout} />
<div
className={
layout === 'aside' ? 'cart-details cart-details--abiir-aside' : 'cart-details'
}
>
{layout === 'aside' && cartHasItems ?
<CartFreeShippingBar subtotal={cart?.cost?.subtotalAmount} />
: null}
<p id="cart-lines" className="sr-only">
Line items
</p>
<div className={layout === 'aside' ? 'cart-aside-lines' : undefined}>
<ul aria-labelledby="cart-lines">
{(cart?.lines?.nodes ?? []).map((line) => {
// we do not render non-parent lines at the root of the cart
if (
'parentRelationship' in line &&
line.parentRelationship?.parent
) {
return null;
}
return (
<CartLineItem
key={line.id}
line={line}
layout={layout}
childrenMap={childrenMap}
/>
);
})}
</ul>
</div>
{cartHasItems && <CartSummary cart={cart} layout={layout} />}
</div>
</section>
);
}
function CartEmpty({
hidden = false,
}: {
hidden: boolean;
layout?: CartMainProps['layout'];
}) {
const {close} = useAside();
return (
<div hidden={hidden} className="cart-empty-aside">
<p className="cart-empty-aside__copy">
Looks like you haven&rsquo;t added anything yet — let&rsquo;s get you started.
</p>
<Link
to="/collections/all"
onClick={close}
prefetch="viewport"
className="cart-empty-aside__link"
>
Continue shopping
</Link>
</div>
);
}

View File

@ -0,0 +1,351 @@
import type {CartApiQueryFragment} from 'storefrontapi.generated';
import type {CartLayout} from '~/components/CartMain';
import {CartForm, Money, type OptimisticCart} from '@shopify/hydrogen';
import {useEffect, useId, useRef, useState} from 'react';
import {Link} from 'react-router';
import {useFetcher} from 'react-router';
import {useAside} from '~/components/Aside';
type CartSummaryProps = {
cart: OptimisticCart<CartApiQueryFragment | null>;
layout: CartLayout;
};
export function CartSummary({cart, layout}: CartSummaryProps) {
const className =
layout === 'page' ? 'cart-summary-page' : 'cart-summary-aside';
const summaryId = useId();
const discountsHeadingId = useId();
const discountCodeInputId = useId();
const giftCardHeadingId = useId();
const giftCardInputId = useId();
if (layout === 'aside') {
return <CartSummaryAside cart={cart} />;
}
return (
<div aria-labelledby={summaryId} className={className}>
<h4 id={summaryId}>Totals</h4>
<dl role="group" className="cart-subtotal">
<dt>Subtotal</dt>
<dd>
{cart?.cost?.subtotalAmount?.amount ? (
<Money data={cart?.cost?.subtotalAmount} />
) : (
'-'
)}
</dd>
</dl>
<CartDiscounts
discountCodes={cart?.discountCodes}
discountsHeadingId={discountsHeadingId}
discountCodeInputId={discountCodeInputId}
/>
<CartGiftCard
giftCardCodes={cart?.appliedGiftCards}
giftCardHeadingId={giftCardHeadingId}
giftCardInputId={giftCardInputId}
/>
<CartCheckoutActions checkoutUrl={cart?.checkoutUrl} />
</div>
);
}
/** Figma cart drawer footer — node 123:13655 */
function CartSummaryAside({cart}: {cart: OptimisticCart<CartApiQueryFragment | null>}) {
const {close} = useAside();
const checkoutUrl = cart?.checkoutUrl;
const subtotal = cart?.cost?.subtotalAmount;
return (
<div className="cart-summary-aside cart-summary-aside--abiir">
<div className="cart-summary-aside__rows">
<div className="cart-summary-aside__row">
<span className="cart-summary-aside__label">Subtotal</span>
<span className="cart-summary-aside__value">
{subtotal?.amount ?
<Money data={subtotal} />
: '—'}
</span>
</div>
<div className="cart-summary-aside__row">
<span className="cart-summary-aside__label">Shipping</span>
<span className="cart-summary-aside__value cart-summary-aside__value--muted">
Calculated at checkout
</span>
</div>
</div>
{checkoutUrl ?
<a href={checkoutUrl} target="_self" className="cart-summary-aside__checkout">
<span className="cart-summary-aside__checkout-label">Checkout</span>
<span className="cart-summary-aside__checkout-arrow" aria-hidden>
→
</span>
</a>
: null}
<Link to="/collections/all" prefetch="viewport" onClick={close} className="cart-summary-aside__continue">
Continue shopping
</Link>
<div className="cart-summary-aside__trust" aria-label="Checkout assurances">
<span>Secure checkout</span>
<span>Free returns</span>
<span>Encrypted</span>
</div>
</div>
);
}
function CartCheckoutActions({checkoutUrl}: {checkoutUrl?: string}) {
if (!checkoutUrl) return null;
return (
<div>
<a href={checkoutUrl} target="_self">
<p>Continue to Checkout &rarr;</p>
</a>
<br />
</div>
);
}
function CartDiscounts({
discountCodes,
discountsHeadingId,
discountCodeInputId,
}: {
discountCodes?: CartApiQueryFragment['discountCodes'];
discountsHeadingId: string;
discountCodeInputId: string;
}) {
const codes: string[] =
discountCodes
?.filter((discount) => discount.applicable)
?.map(({code}) => code) || [];
return (
<section aria-label="Discounts">
<dl hidden={!codes.length}>
<div>
<dt id={discountsHeadingId}>Discounts</dt>
<UpdateDiscountForm>
<div
className="cart-discount"
role="group"
aria-labelledby={discountsHeadingId}
>
<code>{codes?.join(', ')}</code>
&nbsp;
<button type="submit" aria-label="Remove discount">
Remove
</button>
</div>
</UpdateDiscountForm>
</div>
</dl>
<UpdateDiscountForm discountCodes={codes}>
<div>
<label htmlFor={discountCodeInputId} className="sr-only">
Discount code
</label>
<input
id={discountCodeInputId}
type="text"
name="discountCode"
placeholder="Discount code"
/>
&nbsp;
<button type="submit" aria-label="Apply discount code">
Apply
</button>
</div>
</UpdateDiscountForm>
</section>
);
}
function UpdateDiscountForm({
discountCodes,
children,
}: {
discountCodes?: string[];
children: React.ReactNode;
}) {
return (
<CartForm
route="/cart"
action={CartForm.ACTIONS.DiscountCodesUpdate}
inputs={{
discountCodes: discountCodes || [],
}}
>
{children}
</CartForm>
);
}
function CartGiftCard({
giftCardCodes,
giftCardHeadingId,
giftCardInputId,
}: {
giftCardCodes: CartApiQueryFragment['appliedGiftCards'] | undefined;
giftCardHeadingId: string;
giftCardInputId: string;
}) {
const giftCardCodeInput = useRef<HTMLInputElement>(null);
const removeButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const previousCardIdsRef = useRef<string[]>([]);
const giftCardAddFetcher = useFetcher({key: 'gift-card-add'});
const [removedCardIndex, setRemovedCardIndex] = useState<number | null>(null);
useEffect(() => {
if (giftCardAddFetcher.data) {
if (giftCardCodeInput.current !== null) {
giftCardCodeInput.current.value = '';
}
}
}, [giftCardAddFetcher.data]);
useEffect(() => {
const currentCardIds = giftCardCodes?.map((card) => card.id) || [];
if (removedCardIndex !== null && giftCardCodes) {
const focusTargetIndex = Math.min(
removedCardIndex,
giftCardCodes.length - 1,
);
const focusTargetCard = giftCardCodes[focusTargetIndex];
const focusButton = focusTargetCard
? removeButtonRefs.current.get(focusTargetCard.id)
: null;
if (focusButton) {
focusButton.focus();
} else if (giftCardCodeInput.current) {
giftCardCodeInput.current.focus();
}
setRemovedCardIndex(null);
}
previousCardIdsRef.current = currentCardIds;
}, [giftCardCodes, removedCardIndex]);
const handleRemoveClick = (cardId: string) => {
const index = previousCardIdsRef.current.indexOf(cardId);
if (index !== -1) {
setRemovedCardIndex(index);
}
};
return (
<section aria-label="Gift cards">
{giftCardCodes && giftCardCodes.length > 0 && (
<dl>
<dt id={giftCardHeadingId}>Applied Gift Card(s)</dt>
{giftCardCodes.map((giftCard) => (
<dd key={giftCard.id} className="cart-discount">
<RemoveGiftCardForm
giftCardId={giftCard.id}
lastCharacters={giftCard.lastCharacters}
onRemoveClick={() => handleRemoveClick(giftCard.id)}
buttonRef={(el: HTMLButtonElement | null) => {
if (el) {
removeButtonRefs.current.set(giftCard.id, el);
} else {
removeButtonRefs.current.delete(giftCard.id);
}
}}
>
<code>***{giftCard.lastCharacters}</code>
&nbsp;
<Money data={giftCard.amountUsed} />
</RemoveGiftCardForm>
</dd>
))}
</dl>
)}
<AddGiftCardForm fetcherKey="gift-card-add">
<div>
<label htmlFor={giftCardInputId} className="sr-only">
Gift card code
</label>
<input
id={giftCardInputId}
type="text"
name="giftCardCode"
placeholder="Gift card code"
ref={giftCardCodeInput}
/>
&nbsp;
<button
type="submit"
disabled={giftCardAddFetcher.state !== 'idle'}
aria-label="Apply gift card code"
>
Apply
</button>
</div>
</AddGiftCardForm>
</section>
);
}
function AddGiftCardForm({
fetcherKey,
children,
}: {
fetcherKey?: string;
children: React.ReactNode;
}) {
return (
<CartForm
fetcherKey={fetcherKey}
route="/cart"
action={CartForm.ACTIONS.GiftCardCodesAdd}
>
{children}
</CartForm>
);
}
function RemoveGiftCardForm({
giftCardId,
lastCharacters,
children,
onRemoveClick,
buttonRef,
}: {
giftCardId: string;
lastCharacters: string;
children: React.ReactNode;
onRemoveClick?: () => void;
buttonRef?: (el: HTMLButtonElement | null) => void;
}) {
return (
<CartForm
route="/cart"
action={CartForm.ACTIONS.GiftCardCodesRemove}
inputs={{
giftCardCodes: [giftCardId],
}}
>
{children}
&nbsp;
<button
type="submit"
aria-label={`Remove gift card ending in ${lastCharacters}`}
onClick={onRemoveClick}
ref={buttonRef}
>
Remove
</button>
</CartForm>
);
}

View File

@ -0,0 +1,100 @@
import {type ReactNode} from 'react';
import {Link, useLocation} from 'react-router';
const FILTER_TABS: {id: string; label: string; to: string}[] = [
{id: 'all', label: 'All Products', to: '/collections/all'},
{id: 'face', label: 'Face', to: '/collections/face'},
{id: 'ritual', label: 'Ritual', to: '/collections/ritual'},
];
type CollectionPageViewProps = {
title: string;
/** Optional `className` for the H1 (default matches PLP: `max-w-[20ch]`). Use a wider class for long search queries. */
titleClassName?: string;
eyebrow?: string;
/** Subtitle e.g. "8 products" — omit on paginated subpages if unknown */
productCountLine?: string | null;
/** When false, hides the All / Face / Ritual collection tabs (e.g. search page). */
showFilterTabs?: boolean;
children: ReactNode;
};
function buildFilterTo(path: string, search: string) {
const sp = new URLSearchParams(search);
sp.delete('cursor');
sp.delete('direction');
const q = sp.toString();
return q ? `${path}?${q}` : path;
}
function isFilterActive(pathname: string, itemTo: string): boolean {
const path = (pathname.replace(/\/$/, '') || '/') as string;
const to = itemTo.replace(/\/$/, '');
if (to === '/collections/all') {
return path === '/collections/all' || path.endsWith('/collections/all');
}
return path === to || path.endsWith(to);
}
/**
* Figma: Products / collection (node 121:2974) — band header, filter tabs, sort, 4-up grid.
*/
export function CollectionPageView({
title,
titleClassName = "max-w-[20ch]",
eyebrow = 'Our Collection',
productCountLine,
showFilterTabs = true,
children,
}: CollectionPageViewProps) {
const location = useLocation();
return (
<div className="w-full bg-[#f8f4ef]">
<header className="w-full border-b border-[rgba(26,26,24,0.08)] bg-[#ede8e0] pt-[40px] pb-10 sm:pt-[48px] sm:pb-12">
<div className="mx-auto w-full max-w-[1710px] px-6 sm:px-10 lg:px-16 2xl:px-[206px]">
<p className="m-0 font-['Raleway',sans-serif] text-[0.75rem] font-medium uppercase not-italic leading-[1.1] tracking-[0.32em] text-[#5d0b24]">
{eyebrow}
</p>
<h1
className={`m-0 mt-3 break-words font-['Cormorant_Garamond',serif] text-[2.5rem] font-light not-italic leading-[1.08] tracking-[-0.02em] text-[#1a1a18] sm:text-[3rem] lg:text-[4.25rem] lg:leading-[1.02] ${titleClassName}`}
>
{title}
</h1>
{productCountLine ? (
<p className="m-0 mt-3 font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.4] text-[#5a5550]">
{productCountLine}
</p>
) : null}
</div>
</header>
<div className="mx-auto w-full max-w-[1400px] px-4 pb-20 pt-8 sm:px-8 sm:pt-10 lg:px-[51px] lg:pt-[42.5px]">
{showFilterTabs ? (
<div className="mb-6 flex w-full min-h-[42px] flex-col border-b border-[rgba(26,26,24,0.08)] pb-4 sm:mb-8 sm:min-h-[68px] sm:pb-px">
<div className="flex flex-wrap items-center gap-2 sm:gap-3 sm:min-h-[41.5px]">
{FILTER_TABS.map((item) => {
const active = isFilterActive(location.pathname, item.to);
return (
<Link
key={item.to}
className={
active
? "inline-flex min-h-[42px] min-w-[6rem] items-center justify-center [background-color:#1a1a18] px-3 font-['Raleway',sans-serif] text-[0.7rem] font-medium uppercase not-italic leading-none tracking-[0.1em] !text-white no-underline sm:min-h-[41.5px] sm:px-5"
: "inline-flex min-h-[42px] min-w-[4rem] items-center justify-center border border-[rgba(26,26,24,0.1)] [background:transparent] px-3 font-['Raleway',sans-serif] text-[0.7rem] font-medium uppercase not-italic leading-none tracking-[0.1em] text-[#7a7068] no-underline transition-opacity duration-200 hover:opacity-80 sm:min-h-[41.5px] sm:px-5"
}
to={buildFilterTo(item.to, location.search)}
>
{item.label}
</Link>
);
})}
</div>
</div>
) : null}
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,260 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
import {Link} from 'react-router';
import {motion} from 'motion/react';
import {AbiirProductListItem} from '~/components/AbiirProductListItem';
import type {
AbiirProductListItemData,
FeaturedProductsCms,
} from '~/lib/featuredProducts';
import {
MOCK_FEATURED_PRODUCT_ROWS,
MOCK_FEATURED_PRODUCTS_CMS,
} from '~/lib/featuredProducts';
import {PAGE_TITLE_TYPOGRAPHY_CLASS} from '~/lib/pageTitle';
import {useSwipeNavigate} from '~/lib/useSwipeNavigate';
/** Desktop featured grid matches `ABIIR_PRODUCT_LIST_GRID_CLASS` at `lg:` only (`abiirProductListGrid.ts`). */
const FEATURED_DESKTOP_GRID_CLASS =
'hidden w-full max-w-full lg:grid lg:grid-cols-4 lg:gap-x-[34px] lg:gap-y-10';
/**
* GROQ: `home` singleton → featured products (Most Loved) module.
* Use with `context.sanity.loadQuery(FEATURED_PRODUCTS_GROQ)` or `useQuery` in the browser.
*/
export const FEATURED_PRODUCTS_GROQ = `*[_type == "home" && _id == "home"][0].featuredProducts{
eyebrow,
title,
filterPills[]{label, href},
viewAllLabel,
viewAllHref,
items[]{
productHandle,
hideBadge,
cardTitle,
subtitle,
benefitChips,
rating,
reviewCount
}
}` as const;
export type {FeaturedProductsCms, AbiirProductListItemData};
export {MOCK_FEATURED_PRODUCTS_CMS, MOCK_FEATURED_PRODUCT_ROWS};
const sectionBg = '#f8f4ef';
const burgundy = '#5d0b24';
const charcoal = '#1a1a18';
/** Mobile / tablet featured strip: auto-advance interval (desktop keeps grid). */
const FEATURED_MOBILE_SLIDE_MS = 4000;
function FeaturedProductsMobileSlider({rows}: {rows: AbiirProductListItemData[]}) {
const [active, setActive] = useState(0);
const rowsKey = useMemo(
() => rows.map((r) => r.handle).join('\0'),
[rows],
);
useEffect(() => {
setActive(0);
}, [rowsKey]);
useEffect(() => {
if (rows.length <= 1) return;
if (typeof window === 'undefined') return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const id = window.setInterval(() => {
setActive((i) => (i + 1) % rows.length);
}, FEATURED_MOBILE_SLIDE_MS);
return () => window.clearInterval(id);
}, [rows.length]);
const goPrev = useCallback(() => {
if (rows.length <= 1) return;
setActive((i) => (i - 1 + rows.length) % rows.length);
}, [rows.length]);
const goNext = useCallback(() => {
if (rows.length <= 1) return;
setActive((i) => (i + 1) % rows.length);
}, [rows.length]);
const swipe = useSwipeNavigate(goPrev, goNext);
return (
<div
className="w-full overflow-hidden lg:hidden"
aria-roledescription="carousel"
aria-label="Featured products"
{...(rows.length > 1 ? swipe : {})}
>
<div
className="flex transition-transform duration-500 ease-out motion-reduce:transition-none"
style={{transform: `translateX(-${active * 100}%)`}}
>
{rows.map((item, index) => (
<div
key={`${item.handle}-${index}`}
className={`w-full shrink-0 basis-full px-0.5 ${
index !== active ? 'pointer-events-none' : ''
}`}
aria-hidden={index !== active}
>
<AbiirProductListItem
index={index}
item={item}
variant="homepageFeatured"
/>
</div>
))}
</div>
<div
className="mt-6 flex justify-center gap-1.5"
aria-hidden
>
{rows.map((_, i) => (
<span
key={i}
role="presentation"
className="h-1.5 rounded-full transition-all duration-300"
style={{
width: i === active ? 22 : 6,
background: i === active ? burgundy : 'rgba(26,26,24,0.22)',
}}
/>
))}
</div>
</div>
);
}
function isExternalHref(href: string) {
return /^https?:\/\//i.test(href);
}
function FilterLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const className =
'inline-flex min-h-[44.07px] cursor-pointer items-center justify-center border border-[rgba(26,26,24,0.2)] px-[26.5px] py-[13.75px] font-["Jost",sans-serif] text-[12px] font-medium uppercase leading-[16.575px] tracking-[1.326px] text-[#1a1a18] no-underline transition-opacity duration-200 box-border hover:opacity-70';
if (isExternalHref(href)) {
return (
<a href={href} className={className}>
{children}
</a>
);
}
return (
<Link to={href} className={className}>
{children}
</Link>
);
}
function ViewAllLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const className =
'inline-flex min-h-[51.844px] cursor-pointer items-center justify-center bg-[#b39763] px-[34px] py-[17px] font-["Raleway",sans-serif] text-[14px] font-bold uppercase leading-[19.89px] tracking-[2.3868px] text-white no-underline transition-opacity duration-200 box-border hover:opacity-90';
if (isExternalHref(href)) {
return (
<a href={href} className={className}>
{children}
</a>
);
}
return (
<Link to={href} className={className}>
{children}
</Link>
);
}
export type FeaturedProductsProps = {
cms: FeaturedProductsCms;
/** Merged Shopify + Sanity rows (loader) or mock rows for static preview. */
rows: AbiirProductListItemData[];
};
/**
* Homepage “Most Loved” strip — `React/src/app/components/FeaturedProducts.tsx` (Tailwind).
* Placed below `BrandValues` on the index route.
*/
export function FeaturedProducts({cms, rows}: FeaturedProductsProps) {
const pills =
cms.filterPills.length > 0
? cms.filterPills
: MOCK_FEATURED_PRODUCTS_CMS.filterPills;
return (
<section className="relative w-full" style={{background: sectionBg}}>
<div className="mx-auto max-w-[1710px] px-6 pt-16 pb-20 sm:px-10 sm:pt-20 lg:px-16 lg:pb-28 lg:pt-[119px] xl:px-24 2xl:px-[206px]">
<div className="mb-5 flex flex-col gap-4 lg:mb-[68px] lg:flex-row lg:items-end lg:justify-between lg:gap-6">
<div className="max-w-[286px]">
<motion.p
initial={false}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5}}
className="m-0 mb-2 font-['Jost',sans-serif] text-[12px] font-medium uppercase leading-[16.575px] tracking-[2.7625px] not-italic"
style={{color: burgundy}}
>
{cms.eyebrow}
</motion.p>
<motion.h2
initial={false}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.6, delay: 0.05}}
className={`m-0 ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
style={{color: charcoal}}
>
{cms.title}
</motion.h2>
</div>
<nav
className="flex flex-wrap items-center justify-start gap-[13px] lg:justify-end"
aria-label="Product filters"
>
{pills.map((pill, i) => (
<FilterLink key={`${pill.href}-${i}`} href={pill.href}>
{pill.label}
</FilterLink>
))}
</nav>
</div>
<FeaturedProductsMobileSlider rows={rows} />
<div className={FEATURED_DESKTOP_GRID_CLASS}>
{rows.map((item, index) => (
<AbiirProductListItem
key={item.handle + String(index)}
index={index}
item={item}
variant="homepageFeatured"
/>
))}
</div>
<motion.div
initial={false}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.55, delay: 0.15}}
className="mt-14 flex justify-center lg:mt-20"
>
<ViewAllLink href={cms.viewAllHref}>{cms.viewAllLabel}</ViewAllLink>
</motion.div>
</div>
</section>
);
}

View File

@ -0,0 +1,346 @@
import type {CSSProperties, ReactNode} from 'react';
import {Link} from 'react-router';
import {Image} from '@shopify/hydrogen';
import {Instagram, Youtube} from 'lucide-react';
import type {FooterQuery, HeaderQuery} from 'storefrontapi.generated';
import {pickAbiirLogoData, type AbiirHeaderCms} from '~/components/Header';
import type {
AbiirFooterCms,
AbiirFooterColumn,
AbiirFooterLink,
} from '~/lib/footer';
import {MOCK_ABIIR_FOOTER_CMS} from '~/lib/footer';
/**
* GROQ: `settings` singleton → ABiiR footer module.
* Use with `context.sanity.loadQuery(FOOTER_SECTION_GROQ)` or `loadFooterFromSanity`.
*/
export const FOOTER_SECTION_GROQ = `*[_type == "settings" && _id == "settings"][0].abiirFooter{
tagline,
brandLinkPath,
emblem{
alt,
asset->{
url,
metadata{dimensions{width,height}}
}
},
wordmark{
alt,
asset->{
url,
metadata{dimensions{width,height}}
}
},
certificationBadges{
alt,
asset->{
url,
metadata{dimensions{width,height}}
}
},
socialInstagramUrl,
socialYoutubeUrl,
socialXUrl,
columnGroups[]{
title,
links[]{ label, path }
},
copyrightLine,
legalLinks[]{ label, path }
}` as const;
export {MOCK_ABIIR_FOOTER_CMS};
const CREAM = '#faf8f3';
const LINK_MUTED = 'rgba(250, 248, 243, 0.8)';
const BODY_MUTED = 'rgba(250, 248, 243, 0.7)';
const LEGAL_MUTED = 'rgba(250, 248, 243, 0.6)';
const BORDER_TOP = 'rgba(250, 248, 243, 0.15)';
function SocialX({className, size}: {className?: string; size?: number}) {
const s = size ?? 19;
return (
<svg
aria-hidden
className={className}
fill="none"
height={s}
viewBox="0 0 24 24"
width={s}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.522 10.774 20.48 3h-1.646l-6.04 6.748L8.158 3H3l7.304 10.12L3 21h1.646l6.388-7.142L15.842 21H21l-7.478-10.226ZM11.66 13.02l-.732-1.01L5.22 4.2h2.513l4.71 6.488.732 1.01 6.11 8.42h-2.514l-4.99-6.878Z"
fill="currentColor"
/>
</svg>
);
}
function isExternalHref(href: string) {
return /^https?:\/\//i.test(href);
}
function FooterNavLink({
href,
className,
style,
children,
}: {
href: string;
className?: string;
style?: CSSProperties;
children: ReactNode;
}) {
if (isExternalHref(href)) {
return (
<a
className={className}
href={href}
rel="noopener noreferrer"
style={style}
target="_blank"
>
{children}
</a>
);
}
return (
<Link className={className} style={style} to={href}>
{children}
</Link>
);
}
function isRemoteImageUrl(url: string) {
return /^https?:\/\//i.test(url);
}
function FooterRemoteImage({
url,
alt,
width,
height,
className,
style,
sizes,
}: {
url: string;
alt: string;
width: number;
height: number;
className?: string;
style?: CSSProperties;
sizes?: string;
}) {
if (isRemoteImageUrl(url)) {
return (
<Image
alt={alt}
className={className}
data={{
url,
altText: alt,
width,
height,
}}
loader={({src}) => src ?? ''}
sizes={sizes}
style={style}
/>
);
}
return (
<Image
alt={alt}
className={className}
height={height}
sizes={sizes}
src={url}
style={style}
width={width}
/>
);
}
const linkClass =
'font-["Raleway",sans-serif] text-[15.938px] font-light leading-[23.906px] transition-opacity hover:opacity-80 no-underline';
const linkStyle: CSSProperties = {color: LINK_MUTED};
const columnHeadingClass =
'm-0 font-["Raleway",sans-serif] text-[11.688px] font-semibold leading-[15.778px] tracking-[2.5712px] uppercase';
const columnHeadingStyle: CSSProperties = {color: CREAM};
const legalLinkClass =
'font-["Raleway",sans-serif] text-[13.813px] font-light leading-[20.719px] tracking-[0.5525px] transition-opacity hover:opacity-60 whitespace-nowrap no-underline';
const legalLinkStyle: CSSProperties = {color: LEGAL_MUTED};
function FooterNavColumn({column}: {column: AbiirFooterColumn}) {
return (
<div
className="flex min-w-0 flex-col"
style={{gap: '29.75px'}}
>
<h4 className={columnHeadingClass} style={columnHeadingStyle}>
{column.title}
</h4>
<ul className="m-0 flex list-none flex-col p-0" style={{gap: '17px'}}>
{column.links.map((link: AbiirFooterLink) => (
<li key={link.label}>
<FooterNavLink
className={linkClass}
href={link.href}
style={linkStyle}
>
{link.label}
</FooterNavLink>
</li>
))}
</ul>
</div>
);
}
export type AbiirFooterProps = {
/** Shopify menu query (optional; reserved for future use). */
footer: FooterQuery | null;
footerCms: AbiirFooterCms;
header: HeaderQuery;
/** Same source as `Header` — drives footer brand mark (`pickAbiirLogoData`). */
headerCms: AbiirHeaderCms;
publicStoreDomain: string;
};
export function Footer({
footer: _footer,
footerCms,
header,
headerCms,
publicStoreDomain: _publicStoreDomain,
}: AbiirFooterProps) {
const cms = footerCms;
const logoData = pickAbiirLogoData(headerCms, header.shop.brand?.logo?.image);
const columnGroups = cms.columnGroups;
const firstNavPair = columnGroups.slice(0, 2);
const restNavColumns = columnGroups.slice(2);
return (
<footer className="w-full" style={{background: '#1a1a18', color: CREAM}}>
<div className="abiir-footer-main mx-auto max-w-[1710px] px-6 pt-[72px] sm:px-10 sm:pt-[88px] lg:px-16 lg:pt-[102px] 2xl:px-[206px]">
<div className="mx-auto grid max-w-[1298px] grid-cols-1 gap-x-10 gap-y-12 lg:grid-cols-[minmax(0,493.695px)_repeat(3,minmax(0,225.602px))] lg:gap-x-[42.5px] lg:gap-y-0">
{/* Brand */}
<div className="lg:max-w-[493.695px]">
<FooterNavLink
aria-label="ABiiR — Home"
className="mb-5 inline-flex items-center transition-opacity hover:opacity-90"
href={cms.brandLinkPath}
style={{textDecoration: 'none'}}
>
<Image
alt={logoData.altText || 'ABiiR Skincare'}
className="block h-auto w-[148px] max-w-[148px]"
data={logoData}
sizes="148px"
width={148}
/>
</FooterNavLink>
<p
className="m-0 max-w-[340px] font-['Raleway',sans-serif] text-[15.938px] font-light leading-[30.281px]"
style={{color: BODY_MUTED}}
>
{cms.tagline}
</p>
<div
className="mt-8 flex items-center"
style={{gap: '21.25px'}}
>
<a
aria-label="Instagram"
className="flex h-[19px] w-[19px] items-center justify-center transition-opacity hover:opacity-60"
href={cms.socialInstagramUrl}
rel="noopener noreferrer"
style={{color: CREAM}}
target="_blank"
>
<Instagram aria-hidden size={19} strokeWidth={1.5} />
</a>
<a
aria-label="YouTube"
className="flex h-[19px] w-[19px] items-center justify-center transition-opacity hover:opacity-60"
href={cms.socialYoutubeUrl}
rel="noopener noreferrer"
style={{color: CREAM}}
target="_blank"
>
<Youtube aria-hidden size={19} strokeWidth={1.5} />
</a>
<a
aria-label="X"
className="flex h-[19px] w-[19px] items-center justify-center transition-opacity hover:opacity-60"
href={cms.socialXUrl}
rel="noopener noreferrer"
style={{color: CREAM}}
target="_blank"
>
<SocialX size={19} />
</a>
</div>
<div className="mt-8 min-w-0 max-w-full">
<FooterRemoteImage
alt={cms.certificationBadgesAlt}
className="block h-auto w-full max-w-[320px] object-contain opacity-90"
height={cms.certificationBadgesHeight ?? 80}
sizes="(max-width: 1024px) min(100vw, 520px), 320px"
url={cms.certificationBadgesUrl}
width={cms.certificationBadgesWidth ?? 320}
/>
</div>
</div>
{firstNavPair.length >= 2 ? (
<div className="grid min-w-0 grid-cols-2 gap-x-6 gap-y-0 sm:gap-x-10 lg:contents">
{firstNavPair.map((column) => (
<FooterNavColumn key={column.title} column={column} />
))}
</div>
) : (
firstNavPair.map((column) => (
<FooterNavColumn key={column.title} column={column} />
))
)}
{restNavColumns.map((column) => (
<FooterNavColumn key={column.title} column={column} />
))}
</div>
</div>
<div
className="abiir-footer-legal mx-auto max-w-[1400px] px-6 sm:px-10 lg:px-[51px]"
style={{borderTop: `1px solid ${BORDER_TOP}`}}
>
<p
className="m-0 text-center font-['Raleway',sans-serif] text-[13.813px] font-light leading-[24.862px] tracking-[0.5525px] sm:text-left"
style={{color: LEGAL_MUTED}}
>
{cms.copyrightLine}
</p>
<div
className="flex flex-wrap items-center justify-center sm:justify-end"
style={{gap: '29.75px'}}
>
{cms.legalLinks.map((item) => (
<FooterNavLink
key={item.label}
className={legalLinkClass}
href={item.href}
style={legalLinkStyle}
>
{item.label}
</FooterNavLink>
))}
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,857 @@
import {useEffect, useRef, useState} from 'react';
import {Form, Link, useLocation} from 'react-router';
import {
type CartViewPayload,
Image,
useAnalytics,
useOptimisticCart,
} from '@shopify/hydrogen';
import {
ChevronDown,
Heart,
LogOut,
Menu,
Package,
Search,
ShoppingBag,
User,
X,
} from 'lucide-react';
import type {HeaderQuery, CartApiQueryFragment} from 'storefrontapi.generated';
import type {CustomerFragment} from 'customer-accountapi.generated';
import {useAside} from '~/components/Aside';
import {getCustomerDisplayName, getCustomerInitials} from '~/lib/customerDisplay';
import {useAbiirWishlistCount} from '~/hooks/useAbiirWishlistCount';
import abiirLogoSrc from '~/assets/abiir-logo.png';
import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery';
import {useQuery} from '@sanity/react-loader';
import type {QueryResponseInitial} from '@sanity/react-loader';
/** GROQ: header fields on the Shopify template `settings` singleton. */
export const HEADER_SANITY_QUERY = `*[_type == "settings"][0].header{
announcementText,
announcementBackground,
announcementTextColor,
navAccent,
utilityAccent,
"navigationItems": navigationItems[]{
label,
"href": href
},
logo{
alt,
asset->{
_id,
url,
metadata{dimensions{width,height}}
}
},
"accountMenuItems": accountMenuItems[]{
label,
"href": href
}
}` as const;
export type AbiirHeaderNavItem = {
label: string;
href: string;
};
export type AbiirHeaderCms = {
announcementText: string;
announcementBackground: string;
announcementTextColor: string;
navAccent: string;
utilityAccent: string;
navigationItems: AbiirHeaderNavItem[];
logo: {
id?: string;
url: string;
alt: string;
width?: number;
height?: number;
} | null;
accountMenuItems: AbiirHeaderNavItem[];
};
/** Legacy nav used `/products` as a catalog index; this app lists all products at `/collections/all`. */
function normalizeCatalogListNavHref(href: string): string {
try {
const u = new URL(href, 'https://abiir.internal');
if (u.pathname === '/products' || u.pathname === '/products/') {
return `/collections/all${u.search}${u.hash}`;
}
} catch {
/* keep href */
}
return href;
}
type SanityHeaderPayload = {
announcementText?: string | null;
announcementBackground?: string | null;
announcementTextColor?: string | null;
navAccent?: string | null;
utilityAccent?: string | null;
navigationItems?: AbiirHeaderNavItem[] | null;
logo?: {
alt?: string | null;
asset?: {
_id?: string;
url?: string | null;
metadata?: {dimensions?: {width?: number | null; height?: number | null}} | null;
} | null;
} | null;
accountMenuItems?: AbiirHeaderNavItem[] | null;
} | null;
/** Visual / offline fallback — matches the legacy React Navbar defaults. */
export const MOCK_HEADER_CMS: AbiirHeaderCms = {
announcementText:
'FREE SHIPPING ON ORDERS OVER $75 · USE CODE: ABiiR15 FOR 15% OFF',
announcementBackground: '#5D0B24',
announcementTextColor: '#FAF8F3',
navAccent: '#b39763',
utilityAccent: '#5d0b24',
navigationItems: [
{label: 'Collection', href: '/collections/all'},
{label: 'Rituals', href: '/collections/ritual'},
{label: 'Our Story', href: '/about'},
// {label: 'Journal', href: '/journal'},
],
logo: null,
accountMenuItems: [
{label: 'My Profile', href: '/account/profile'},
{label: 'My Orders', href: '/account/orders'},
{label: 'Wishlist', href: '/wishlist'},
],
};
const DEFAULT_ACCOUNT_MENU: AbiirHeaderNavItem[] = [
{label: 'My Profile', href: '/account/profile'},
{label: 'My Orders', href: '/account/orders'},
{label: 'Wishlist', href: '/wishlist'},
];
export function normalizeHeaderSanityPayload(
raw: unknown,
): AbiirHeaderCms | null {
if (raw == null || typeof raw !== 'object') return null;
const h = raw as SanityHeaderPayload;
if (!h?.announcementText || !h.navigationItems?.length) return null;
const logoUrl = h.logo?.asset?.url;
const logo =
logoUrl != null
? {
id: h.logo?.asset?._id,
url: logoUrl,
alt: h.logo?.alt ?? 'ABiiR Skincare – Rooted in Ayurveda',
width: h.logo?.asset?.metadata?.dimensions?.width ?? undefined,
height: h.logo?.asset?.metadata?.dimensions?.height ?? undefined,
}
: null;
return {
announcementText: h.announcementText,
announcementBackground: h.announcementBackground ?? '#5D0B24',
announcementTextColor: h.announcementTextColor ?? '#FAF8F3',
navAccent: h.navAccent ?? '#b39763',
utilityAccent: h.utilityAccent ?? '#5d0b24',
navigationItems: h.navigationItems.map((i) => ({
label: i.label,
href: normalizeCatalogListNavHref(i.href ?? ''),
})),
logo,
accountMenuItems:
h.accountMenuItems?.filter((i) => i.label && i.href) ??
DEFAULT_ACCOUNT_MENU,
};
}
type ShopifyBrandImage = NonNullable<
NonNullable<HeaderQuery['shop']['brand']>['logo']
>['image'];
/** Shopify brand logo image from `HeaderQuery` (pass `header.shop.brand?.logo?.image`). */
export type AbiirShopifyBrandLogoImage = ShopifyBrandImage;
export type AbiirLogoImageData = NonNullable<
ReturnType<typeof toHydrogenImage>
>;
function toHydrogenImage(
input: {
id?: string | null;
url: string;
altText?: string | null;
width?: number | null;
height?: number | null;
} | null | undefined,
): {
id?: string | undefined;
url: string;
altText?: string | undefined;
width?: number | undefined;
height?: number | undefined;
} | null {
if (!input?.url) return null;
return {
id: input.id ?? undefined,
url: input.url,
altText: input.altText ?? '',
width: input.width ?? undefined,
height: input.height ?? undefined,
};
}
/** Same artwork as React `Navbar` (`figma:asset/11567d6fddae52ee62a5633b5f842f2e7b3e3b29.png` → `app/assets/abiir-logo.png`). */
const REACT_APP_BRAND_LOGO = toHydrogenImage({
id: 'abiir-logo-react',
url: abiirLogoSrc,
altText: 'ABiiR Skincare – Rooted in Ayurveda',
});
/** Sanity header logo → Shopify brand logo → bundled `abiir-logo.png` (header + footer). */
export function pickAbiirLogoData(
cms: AbiirHeaderCms,
shopifyImage: ShopifyBrandImage | null | undefined,
): AbiirLogoImageData {
if (cms.logo?.url) {
const fromCms = toHydrogenImage({
id: cms.logo.id,
url: cms.logo.url,
altText: cms.logo.alt,
width: cms.logo.width,
height: cms.logo.height,
});
if (fromCms) return fromCms;
}
const fromShopify = toHydrogenImage(shopifyImage);
if (fromShopify) return fromShopify;
return REACT_APP_BRAND_LOGO;
}
function isExternalHref(href: string) {
return /^https?:\/\//i.test(href);
}
function NavLinkOrA({
href,
className,
style,
onClick,
children,
}: {
href: string;
className?: string;
style?: React.CSSProperties;
onClick?: () => void;
children: React.ReactNode;
}) {
if (isExternalHref(href)) {
return (
<a
href={href}
className={className}
style={style}
onClick={onClick}
rel="noreferrer"
>
{children}
</a>
);
}
return (
<Link
to={href}
className={className}
style={style}
onClick={onClick}
prefetch="intent"
>
{children}
</Link>
);
}
export interface HeaderProps {
header: HeaderQuery;
headerCms: AbiirHeaderCms;
/** Pass through from root `loadQuery` for `useQuery` hydration (optional). */
headerSanityInitial?: QueryResponseInitial<AbiirHeaderCms | null>;
cart: CartApiQueryFragment | null;
isLoggedIn: boolean;
accountCustomer: CustomerFragment | null;
publicStoreDomain: string;
}
/**
* Optional: use with a mounted Sanity query store / preview (e.g. `hydrogen-sanity` `Query`).
* The default `Header` uses CMS data from the root loader only.
*/
export function useAbiirHeaderCms(
initial: QueryResponseInitial<AbiirHeaderCms | null>,
) {
return useQuery<AbiirHeaderCms | null>(HEADER_SANITY_QUERY, undefined, {
initial,
});
}
export function Header({
header,
headerCms: headerCmsFromLoader,
headerSanityInitial: _headerSanityInitial,
isLoggedIn,
cart,
accountCustomer,
publicStoreDomain,
}: HeaderProps) {
void _headerSanityInitial;
const cms = headerCmsFromLoader ?? MOCK_HEADER_CMS;
const {shop} = header;
const primaryDomainUrl = shop.primaryDomain?.url ?? '';
const shopifyLogo = shop.brand?.logo?.image;
const logoData = pickAbiirLogoData(cms, shopifyLogo);
return (
<>
<div
className="w-full text-center py-[10px] px-4 font-['Raleway',sans-serif]"
style={{
background: cms.announcementBackground,
color: cms.announcementTextColor,
}}
>
<p className="m-0 text-[0.8125rem] font-normal tracking-[0.18em]">
{cms.announcementText}
</p>
</div>
<AbiirNavBar
cart={cart}
cms={cms}
isLoggedIn={isLoggedIn}
accountCustomer={accountCustomer}
logoData={logoData}
shopName={shop.name}
primaryDomainUrl={primaryDomainUrl}
publicStoreDomain={publicStoreDomain}
/>
</>
);
}
function AbiirNavBar({
cms,
cart,
isLoggedIn,
accountCustomer,
logoData,
shopName,
primaryDomainUrl,
publicStoreDomain,
}: {
cms: AbiirHeaderCms;
cart: CartApiQueryFragment | null;
isLoggedIn: boolean;
accountCustomer: CustomerFragment | null;
logoData: AbiirLogoImageData;
shopName: string;
primaryDomainUrl: string;
publicStoreDomain: string;
}) {
const wishlistCount = useAbiirWishlistCount();
const [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const location = useLocation();
const userMenuRef = useRef<HTMLDivElement>(null);
const navAccent = cms.navAccent;
const utilityBurgundy = cms.utilityAccent;
const accountLinks =
cms.accountMenuItems?.length > 0
? cms.accountMenuItems
: DEFAULT_ACCOUNT_MENU;
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 40);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
useEffect(() => {
setMenuOpen(false);
setUserMenuOpen(false);
}, [location.pathname, location.search]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (
userMenuRef.current &&
!userMenuRef.current.contains(e.target as Node)
) {
setUserMenuOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const navLinks = cms.navigationItems;
return (
<nav
className="sticky top-0 z-50 w-full transition-[background,backdrop-filter] duration-300"
style={{
background: scrolled
? 'rgba(250, 248, 243, 0.97)'
: '#FAF8F3',
backdropFilter: scrolled ? 'blur(12px)' : 'none',
borderBottom: '1px solid rgba(0, 0, 0, 0.2)',
}}
>
<div className="max-w-[1600px] mx-auto px-6 sm:px-10 lg:px-16 xl:px-24 2xl:px-[206px]">
<div className="flex items-center justify-between h-[72px] lg:h-[96px] gap-4">
<div className="hidden lg:flex flex-1 items-center justify-between min-w-0 pr-6">
<Link
to="/"
prefetch="intent"
className="flex items-center select-none shrink-0 no-underline"
>
{logoData ? (
<Image
data={logoData}
alt={logoData.altText || 'ABiiR Skincare'}
width={148}
sizes="148px"
className="block w-[148px] h-auto max-w-[148px]"
/>
) : (
<span className="font-['Cormorant_Garamond',serif] text-[1.25rem] text-[#1A1A1A]">
{shopName}
</span>
)}
</Link>
<div className="flex items-center gap-10 shrink-0">
{navLinks.map((link) => {
const [pathname, query = ''] = link.href.split('?');
const isActive =
location.pathname === pathname &&
(!query || location.search.slice(1) === query);
return (
<NavLinkOrA
key={link.href}
href={normalizeStoreHref(
link.href,
primaryDomainUrl,
publicStoreDomain,
)}
className="flex flex-col gap-[3px] items-stretch transition-opacity duration-200 hover:opacity-60 no-underline uppercase font-medium text-[#1A1A1A] font-['Raleway',sans-serif] text-[13.813px] leading-[20.719px] tracking-[2.21px]"
>
<span>{link.label}</span>
<span
className="h-px w-full transition-transform duration-300 origin-left"
style={{
background: navAccent,
transform: isActive ? 'scaleX(1)' : 'scaleX(0)',
}}
/>
</NavLinkOrA>
);
})}
</div>
</div>
<Link
to="/"
prefetch="intent"
className="flex lg:hidden items-center select-none shrink-0 no-underline"
>
{logoData ? (
<Image
data={logoData}
alt={logoData.altText || 'ABiiR Skincare'}
width={132}
className="block w-[132px] h-auto max-w-[132px]"
sizes="132px"
/>
) : (
<span className="font-['Cormorant_Garamond',serif] text-[1.1rem] text-[#1A1A1A]">
{shopName}
</span>
)}
</Link>
<div
className="flex items-center h-[30px] gap-6 lg:gap-[39px] shrink-0 pl-6 border-l border-[rgba(26,26,24,0.10)]"
>
<SearchToggle className="hover:opacity-50 transition-opacity hidden lg:flex lg:items-center text-[#1A1A1A]" />
<NavLinkOrA
href="/wishlist"
className="relative hover:opacity-50 transition-opacity hidden lg:flex lg:items-center text-[#1A1A1A]"
aria-label="Wishlist"
>
<Heart size={18} strokeWidth={1.4} />
{wishlistCount > 0 && (
<span
className="absolute flex items-center justify-center rounded-full min-w-[15px] h-[15px] left-[9px] top-[-6px] px-1 text-white font-['Raleway',sans-serif] font-semibold text-[8.5px] leading-[12.75px]"
style={{background: utilityBurgundy}}
>
{wishlistCount > 99 ? '99+' : wishlistCount}
</span>
)}
</NavLinkOrA>
<div
className="hidden lg:flex lg:items-center relative"
ref={userMenuRef}
>
{isLoggedIn ? (
<AccountDropdown
customer={accountCustomer}
userMenuOpen={userMenuOpen}
setUserMenuOpen={setUserMenuOpen}
utilityBurgundy={utilityBurgundy}
accountLinks={accountLinks}
/>
) : (
<Link
to="/account/login"
prefetch="intent"
className="hover:opacity-50 transition-opacity flex items-center text-[#1A1A1A] bg-transparent border-none cursor-pointer p-0"
aria-label="Sign in"
>
<User size={18} strokeWidth={1.4} />
</Link>
)}
</div>
<CartToggle cart={cart} utilityBurgundy={utilityBurgundy} />
<button
type="button"
className="lg:hidden hover:opacity-50 transition-opacity text-[#1A1A1A] bg-transparent border-none cursor-pointer p-0"
onClick={() => setMenuOpen(!menuOpen)}
aria-label="Menu"
>
{menuOpen ? (
<X size={21} strokeWidth={1.4} />
) : (
<Menu size={21} strokeWidth={1.4} />
)}
</button>
</div>
</div>
</div>
<div
className="lg:hidden overflow-hidden transition-all duration-500"
style={{
maxHeight: menuOpen ? '700px' : '0px',
background: '#FAF8F3',
borderTop: menuOpen ? '1px solid rgba(26, 26, 26, 0.08)' : 'none',
}}
>
<div className="px-8 py-8 flex flex-col gap-6">
{navLinks.map((link) => (
<NavLinkOrA
key={link.href}
href={normalizeStoreHref(
link.href,
primaryDomainUrl,
publicStoreDomain,
)}
onClick={() => setMenuOpen(false)}
className="no-underline font-['Cormorant_Garamond',serif] text-[1.75rem] font-light text-[#1A1A1A] tracking-[0.02em]"
>
{link.label}
</NavLinkOrA>
))}
<div
className="pt-6 flex flex-col gap-4 border-t border-[rgba(26,26,26,0.08)]"
>
<MobileSearchButton onOpen={() => setMenuOpen(false)} />
<NavLinkOrA
href="/wishlist"
onClick={() => setMenuOpen(false)}
className="font-['Raleway',sans-serif] text-[0.8125rem] tracking-[0.14em] text-[#5A5550] uppercase no-underline"
>
Wishlist {wishlistCount > 0 && `(${wishlistCount})`}
</NavLinkOrA>
{isLoggedIn ? (
<>
<NavLinkOrA
href="/account/profile"
onClick={() => setMenuOpen(false)}
className="font-['Raleway',sans-serif] text-[0.8125rem] tracking-[0.14em] text-[#5A5550] uppercase no-underline"
>
My Profile
</NavLinkOrA>
<Form method="post" action="/account/logout">
<button
type="submit"
className="bg-transparent border-none cursor-pointer font-['Raleway',sans-serif] text-[0.8125rem] tracking-[0.14em] text-[#c0392b] uppercase text-left p-0"
onClick={() => setMenuOpen(false)}
>
Sign Out
</button>
</Form>
</>
) : (
<Link
to="/account/login"
prefetch="intent"
onClick={() => setMenuOpen(false)}
className="bg-transparent border-none cursor-pointer font-['Raleway',sans-serif] text-[0.8125rem] tracking-[0.14em] text-[#7A2340] uppercase text-left p-0 no-underline"
>
Sign In / Register
</Link>
)}
</div>
</div>
</div>
</nav>
);
}
function MobileSearchButton({onOpen}: {onOpen: () => void}) {
const {open} = useAside();
return (
<button
type="button"
onClick={() => {
onOpen();
open('search');
}}
className="text-left bg-transparent border-none cursor-pointer p-0 font-['Raleway',sans-serif] text-[0.8125rem] tracking-[0.14em] text-[#5A5550] uppercase"
>
Search
</button>
);
}
function normalizeStoreHref(
href: string,
primaryDomainUrl: string,
publicStoreDomain: string,
) {
if (isExternalHref(href)) return href;
if (
href.includes('myshopify.com') ||
(publicStoreDomain && href.includes(publicStoreDomain)) ||
(primaryDomainUrl && href.includes(primaryDomainUrl))
) {
try {
return new URL(href).pathname;
} catch {
return href;
}
}
return href;
}
function AccountDropdown({
customer,
userMenuOpen,
setUserMenuOpen,
utilityBurgundy,
accountLinks,
}: {
customer: CustomerFragment | null;
userMenuOpen: boolean;
setUserMenuOpen: (v: boolean | ((b: boolean) => boolean)) => void;
utilityBurgundy: string;
accountLinks: AbiirHeaderNavItem[];
}) {
const initials = getInitials(customer);
const displayName = getDisplayName(customer);
const email = customer?.emailAddress?.emailAddress ?? '';
return (
<>
<button
type="button"
onClick={() => setUserMenuOpen((o) => !o)}
className="flex items-center gap-[8.5px] hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer p-0 text-[#1A1A1A] h-[30px]"
aria-label="Account menu"
>
<div
className="w-[30px] h-[30px] rounded-[15px] flex items-center justify-center"
style={{background: utilityBurgundy}}
>
<span className="font-['Cormorant_Garamond',serif] text-[13.813px] font-medium text-[#FAF8F3] tracking-[1.63px] leading-[20.719px] uppercase">
{initials}
</span>
</div>
<ChevronDown
size={13}
strokeWidth={1.8}
className="text-[#1A1A1A] transition-transform duration-200"
style={{transform: userMenuOpen ? 'rotate(180deg)' : 'none'}}
/>
</button>
{userMenuOpen && (
<div className="absolute top-[calc(100%+14px)] right-0 min-w-[220px] z-[200] bg-[#FAF8F3] border border-[rgba(26,26,26,0.10)] shadow-[0_16px_48px_rgba(26,26,26,0.13)]">
<div className="px-5 py-4 border-b border-[rgba(26,26,26,0.07)]">
<p className="font-['Cormorant_Garamond',serif] text-[1.0625rem] font-normal text-[#1A1A1A] leading-[1.2] m-0">
{displayName}
</p>
{email ? (
<p className="font-['Raleway',sans-serif] text-[0.75rem] text-[#8A8480] font-light mt-[3px] m-0">
{email}
</p>
) : (
<p className="font-['Raleway',sans-serif] text-[0.75rem] text-[#8A8480] font-light mt-[3px] m-0 min-h-[14px]" />
)}
</div>
{accountLinks.map((item) => (
<NavLinkOrA
key={item.label}
href={item.href}
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-[10px] px-5 py-[13px] font-['Raleway',sans-serif] text-[0.8125rem] font-normal text-[#1A1A1A] no-underline border-b border-[rgba(26,26,26,0.05)] transition-colors duration-150 hover:bg-[#F5F1E8]"
>
<span className="text-[#7A2340]">
{item.label === 'My Profile' ? (
<User size={14} strokeWidth={1.5} />
) : item.label.toLowerCase().includes('order') ? (
<Package size={14} strokeWidth={1.5} />
) : (
<Heart size={14} strokeWidth={1.5} />
)}
</span>
{item.label}
</NavLinkOrA>
))}
<Form method="post" action="/account/logout" className="m-0">
<button
type="submit"
className="w-full flex items-center gap-[10px] px-5 py-[13px] bg-transparent border-none cursor-pointer font-['Raleway',sans-serif] text-[0.8125rem] font-normal text-[#c0392b] text-left normal-case tracking-normal hover:bg-[#fce4ec] transition-colors duration-150"
>
<LogOut size={14} strokeWidth={1.5} /> Sign Out
</button>
</Form>
</div>
)}
</>
);
}
function getInitials(customer: CustomerFragment | null) {
return getCustomerInitials(customer);
}
function getDisplayName(customer: CustomerFragment | null) {
if (!customer) return 'Customer';
return getCustomerDisplayName(customer) || 'Customer';
}
function SearchToggle({className}: {className?: string}) {
const {open} = useAside();
return (
<button
type="button"
className={className}
onClick={() => open('search')}
aria-label="Search"
>
<Search size={18} strokeWidth={1.4} />
</button>
);
}
function CartToggle({
cart,
utilityBurgundy,
}: {
cart: CartApiQueryFragment | null;
utilityBurgundy: string;
}) {
const cartState = useOptimisticCart(cart);
return (
<CartBadge
count={cartState?.totalQuantity ?? 0}
utilityBurgundy={utilityBurgundy}
/>
);
}
function CartBadge({
count,
utilityBurgundy,
}: {
count: number;
utilityBurgundy: string;
}) {
const {open} = useAside();
const {publish, shop, cart, prevCart} = useAnalytics();
return (
<button
type="button"
onClick={(e) => {
e.preventDefault();
open('cart');
publish('cart_viewed', {
cart,
prevCart,
shop,
url: typeof window !== 'undefined' ? window.location.href : '',
} as CartViewPayload);
}}
className="relative hover:opacity-50 transition-opacity flex items-center text-[#1A1A1A] bg-transparent border-none cursor-pointer p-0"
aria-label="Cart"
>
<ShoppingBag size={18} strokeWidth={1.4} />
{count > 0 && (
<span
className="absolute flex items-center justify-center rounded-full min-w-[15px] h-[15px] left-[9px] top-[-6px] px-1 text-white font-['Raleway',sans-serif] font-semibold text-[8.5px] leading-[12.75px]"
style={{background: utilityBurgundy}}
>
{count > 99 ? '99+' : count}
</span>
)}
</button>
);
}
export async function loadAbiirHeaderCms(args: {
sanity: import('hydrogen-sanity').SanityContext;
}): Promise<{
headerCms: AbiirHeaderCms;
headerSanityInitial: QueryResponseInitial<AbiirHeaderCms | null>;
}> {
const initial = await args.sanity
.loadQuery<SanityHeaderPayload>(HEADER_SANITY_QUERY)
.catch(
(): QueryResponseInitial<SanityHeaderPayload> => ({
data: null,
sourceMap: undefined,
}),
);
const normalized = normalizeHeaderSanityPayload(initial.data);
return {
headerCms: normalized ?? MOCK_HEADER_CMS,
headerSanityInitial: {
data: normalized,
sourceMap: undefined,
},
};
}
export async function loadAccountCustomer(context: {
customerAccount: import('@shopify/hydrogen').CustomerAccount;
}) {
const {customerAccount} = context;
const loggedIn = await customerAccount.isLoggedIn();
if (!loggedIn) return null;
await customerAccount.handleAuthStatus();
const {data, errors} = await customerAccount.query(CUSTOMER_DETAILS_QUERY, {
variables: {
language: customerAccount.i18n.language,
},
});
if (errors?.length || !data?.customer) return null;
return data.customer;
}

View File

@ -0,0 +1,279 @@
import {Link} from 'react-router';
import {Image} from '@shopify/hydrogen';
import {motion} from 'motion/react';
import {
Beaker,
Droplets,
Flame,
Leaf,
PillBottle,
type LucideIcon,
} from 'lucide-react';
import type {
IngredientsIconKey,
IngredientsSectionCms,
} from '~/lib/ingredients';
import {MOCK_INGREDIENTS_SECTION_CMS} from '~/lib/ingredients';
import {PAGE_TITLE_TYPOGRAPHY_CLASS} from '~/lib/pageTitle';
/** Same file as `React/src/assets/fff9b697e083c70c2aa6491b8a1715c65334534.png` (copied). */
import ingredientsHeroFallbackSrc from '~/assets/ingredients-hero.png';
/**
* GROQ: `home` singleton → ingredients philosophy module.
* Use with `context.sanity.loadQuery(INGREDIENTS_SECTION_GROQ)` or `useQuery` in the browser.
*/
export const INGREDIENTS_SECTION_GROQ = `*[_type == "home" && _id == "home"][0].ingredientsSection{
eyebrow,
headlineLine1Before,
headlineLine1Accent,
headlineLine2,
body,
badgePercent,
badgeCaption,
primaryCtaLabel,
primaryCtaHref,
sectionBackground,
gold,
cream,
creamMuted,
badgeBackground,
charcoal,
image{
alt,
asset->{
_id,
url,
metadata{dimensions{width,height}}
}
},
ingredients[]{
iconKey,
name,
description
}
}` as const;
export {MOCK_INGREDIENTS_SECTION_CMS};
export type {IngredientsSectionCms};
const ICONS: Record<IngredientsIconKey, LucideIcon> = {
leaf: Leaf,
flame: Flame,
pillBottle: PillBottle,
droplets: Droplets,
beaker: Beaker,
};
function isExternalHref(href: string) {
return /^https?:\/\//i.test(href);
}
function IngredientsHeroImage({cms}: {cms: IngredientsSectionCms}) {
const alt =
cms.image?.alt ?? 'Ayurvedic ingredients and ritual';
if (cms.image?.url) {
const w = cms.image.width ?? 1200;
const h = cms.image.height ?? 900;
return (
<Image
alt={alt}
className="block h-auto max-w-full w-full object-contain"
data={{
url: cms.image.url,
altText: alt,
width: w,
height: h,
}}
loader={({src}) => src ?? ''}
sizes="(min-width: 1024px) 525px, 100vw"
/>
);
}
return (
<Image
alt={alt}
className="block h-auto max-w-full w-full object-contain"
height={812}
sizes="(min-width: 1024px) 525px, 100vw"
src={ingredientsHeroFallbackSrc}
width={525}
/>
);
}
function PrimaryCta({
href,
background,
children,
}: {
href: string;
background: string;
children: React.ReactNode;
}) {
const className =
'inline-flex min-h-[51.844px] cursor-pointer items-center justify-center px-[34px] py-[17px] font-["Raleway",sans-serif] text-[14px] font-bold uppercase leading-[19.89px] tracking-[2.3868px] text-white no-underline transition-opacity duration-200 box-border hover:opacity-90';
const style = {
boxSizing: 'border-box' as const,
background,
};
if (isExternalHref(href)) {
return (
<a href={href} className={className} style={style}>
{children}
</a>
);
}
return (
<Link to={href} className={className} style={style}>
{children}
</Link>
);
}
export type IngredientsProps = {
cms: IngredientsSectionCms;
};
/**
* `React/src/app/components/Ingredients.tsx` — Tailwind + Hydrogen `Image`, CMS-driven.
*/
export function Ingredients({cms}: IngredientsProps) {
const bg = cms.sectionBackground;
const gold = cms.gold;
const cream = cms.cream;
const creamMuted = cms.creamMuted;
const badgeBg = cms.badgeBackground;
const charcoal = cms.charcoal;
return (
<section
className="w-full overflow-x-clip overflow-y-visible"
style={{background: bg}}
>
<div className="mx-auto max-w-[1710px] px-6 pt-16 pb-16 sm:px-10 sm:pt-20 sm:pb-20 lg:px-16 lg:pt-[94px] lg:pb-[94px] xl:px-24 2xl:px-[206px]">
<div className="flex flex-col gap-6 lg:flex-row lg:items-stretch lg:justify-between lg:gap-10 xl:gap-16">
<motion.div
initial={false}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true, margin: '-60px'}}
transition={{duration: 0.75, ease: [0.25, 0.1, 0.25, 1]}}
className="relative w-full shrink-0 overflow-visible lg:w-[min(100%,525px)] lg:max-w-[525px]"
>
<div className="relative w-full overflow-hidden">
<IngredientsHeroImage cms={cms} />
</div>
<div
className="abiir-ingredients-badge absolute top-4 right-[-10px] z-[1] text-left lg:top-[41.62px]"
style={{
background: badgeBg,
padding: '10px 25px',
}}
>
<p
className="abiir-ingredients-badge-percent"
style={{color: charcoal,fontSize:'2rem'}}
>
{cms.badgePercent}
</p>
<p
className="abiir-ingredients-badge-caption"
style={{color: charcoal}}
>
{cms.badgeCaption}
</p>
</div>
</motion.div>
<div className="flex w-full flex-col gap-5 lg:max-w-[635px] lg:justify-center lg:gap-10">
<div className="flex flex-col gap-4 lg:gap-6">
<div className="flex flex-col gap-2 lg:gap-4">
<p
className="m-0 font-['Jost',sans-serif] text-[12px] font-medium uppercase leading-[16.575px] tracking-[2.7625px] not-italic"
style={{color: gold}}
>
{cms.eyebrow}
</p>
<h2
className={`m-0 ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
style={{color: cream}}
>
<span className="block">
{cms.headlineLine1Before}
{cms.headlineLine1Before && (cms.headlineLine1Accent || cms.headlineLine2)
? ' '
: ''}
{cms.headlineLine1Accent ? (
<span
style={{fontStyle: 'italic', fontWeight: 300, color: gold}}
>
{cms.headlineLine1Accent}
</span>
) : null}
{cms.headlineLine1Accent && cms.headlineLine2 ? ' ' : ''}
{cms.headlineLine2}
</span>
</h2>
</div>
<p
className="m-0 font-['Raleway',sans-serif] text-[16px] font-medium leading-[26.18px] not-italic"
style={{color: cream}}
>
{cms.body}
</p>
<ul className="m-0 flex list-none flex-col gap-6 p-0">
{cms.ingredients.map(({name, description, iconKey}, index) => {
const Icon = ICONS[iconKey];
return (
<motion.li
key={`${name}-${index}`}
initial={false}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true, margin: '-40px'}}
transition={{duration: 0.5, delay: 0.05 * index}}
className="flex items-start gap-4 pb-2 pr-4"
>
<span className="mt-0.5 shrink-0" style={{color: gold}}>
<Icon size={22} strokeWidth={1.35} aria-hidden />
</span>
<div className="flex min-w-0 flex-col gap-1">
<h3
className="m-0 font-['Cormorant_Garamond',serif] text-[24px] font-medium tracking-[-0.187px] not-italic"
style={{color: cream}}
>
{name}
</h3>
<p
className="m-0 overflow-hidden text-ellipsis whitespace-nowrap font-['Raleway',sans-serif] text-[16px] font-medium leading-[23.698px] not-italic"
style={{color: creamMuted}}
title={description}
>
{description}
</p>
</div>
</motion.li>
);
})}
</ul>
</div>
<motion.div
initial={false}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2}}
>
<PrimaryCta href={cms.primaryCtaHref} background={gold}>
{cms.primaryCtaLabel}
</PrimaryCta>
</motion.div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,393 @@
import {useState} from 'react';
import {Image} from '@shopify/hydrogen';
import {Link} from 'react-router';
import {motion} from 'motion/react';
import type {CSSProperties} from 'react';
import type {AbiirJournalIndexCms, AbiirJournalPostCms} from '~/lib/journal';
import {abiirJournalImageIsRemote, MOCK_ABIIR_JOURNAL_INDEX} from '~/lib/journal';
import type {NewsletterSectionCms} from '~/lib/newsletter';
import {Newsletter} from '~/components/Newsletter';
import {PAGE_TITLE_TYPOGRAPHY_CLASS} from '~/lib/pageTitle';
import iconClockFeatured from '~/assets/journal/icon-clock-featured.svg';
import iconClockGrid from '~/assets/journal/icon-clock-grid.svg';
import iconReadArrow from '~/assets/journal/icon-read-arrow.svg';
/**
* Bundle: journal index singleton + all posts.
* Use with `context.sanity.loadQuery(JOURNAL_PAGE_GROQ)` or `useQuery`.
*/
export const JOURNAL_PAGE_GROQ = `{
"index": *[_type == "journalIndex" && _id == "journalIndex"][0]{
seo{ title, description },
eyebrow,
headline,
description,
categoryFilters
},
"posts": *[_type == "journalPost"] | order(_updatedAt desc) {
"slug": slug.current,
title,
subtitle,
excerpt,
cardExcerpt,
content,
author,
authorRole,
date,
readTime,
category,
featured,
tags,
image{
alt,
asset->{
_id,
url,
metadata{dimensions{width,height}}
}
}
}
}` as const;
export {MOCK_ABIIR_JOURNAL_INDEX};
export type {AbiirJournalIndexCms, AbiirJournalPostCms};
const CHARCOAL = '#1a1a18';
const ACCENT = '#c17d5c';
const MUTED = '#5a5550';
const MUTED_MID = '#7a7068';
const CREAM_TEXT = '#f8f4ef';
const HERO_BG = '#ede8e0';
const BORDER_HAIRLINE = 'rgba(26,26,24,0.08)';
const BORDER_BTN = 'rgba(26,26,24,0.15)';
const DOT_MUTED = 'rgba(26,26,24,0.15)';
function gridCardTeaser(post: AbiirJournalPostCms) {
if (post.cardExcerpt) return post.cardExcerpt;
const t = post.excerpt.trim();
return t.length > 100 ? `${t.slice(0, 97).trim()}...` : t;
}
/**
* Hydrogen `Image` with numeric `width`/`height` uses FixedWidthImage and fixed `<img>` dimensions.
* Pairing that with `absolute inset-0` broke rendering. Use fluid images (`width="100%"`) + `aspectRatio`.
*/
function JournalPostImage({
url,
alt,
className,
sizes,
style,
aspectRatio,
}: {
url: string;
alt: string;
className?: string;
sizes: string;
style?: CSSProperties;
aspectRatio: '4/3' | '8/5';
}) {
const imgClass = `block size-full object-cover ${className ?? ''}`;
if (abiirJournalImageIsRemote(url)) {
return (
<Image
alt={alt}
aspectRatio={aspectRatio}
className={imgClass}
data={{
url,
altText: alt,
width: 1200,
height: 800,
}}
height="auto"
loader={({src}) => src ?? ''}
sizes={sizes}
style={style}
width="100%"
/>
);
}
return (
<Image
alt={alt}
aspectRatio={aspectRatio}
className={imgClass}
height="auto"
sizes={sizes}
src={url}
style={style}
width="100%"
/>
);
}
export type JournalPageProps = {
indexCms: AbiirJournalIndexCms;
postsCms: AbiirJournalPostCms[];
newsletterCms: NewsletterSectionCms;
};
export function JournalPage({indexCms, postsCms, newsletterCms}: JournalPageProps) {
const [activeCategory, setActiveCategory] = useState('All');
const blogCategories = indexCms.categoryFilters;
const filtered =
activeCategory === 'All'
? postsCms
: postsCms.filter((p) => p.category === activeCategory);
const featured = postsCms.filter((p) => p.featured);
const rest = filtered.filter(
(p) => !p.featured || activeCategory !== 'All',
);
return (
<div className="min-h-screen bg-[#f8f4ef]">
<header className="w-full bg-[#ede8e0]">
<div className="mx-auto max-w-[1710px] px-6 sm:px-10 lg:px-16 2xl:px-[206px]">
<motion.div
animate={{opacity: 1, y: 0}}
className="mx-auto max-w-[1298px] pb-10 pt-[57px] text-center sm:pb-12"
initial={{opacity: 0, y: 20}}
style={{minHeight: 'min(258px, auto)'}}
transition={{duration: 0.55}}
>
<p
className="m-0 font-['Jost',sans-serif] text-[11.05px] font-medium uppercase leading-[16.575px] tracking-[2.7625px] not-italic"
style={{color: ACCENT}}
>
{indexCms.eyebrow}
</p>
<h1
className={`m-0 mt-[17.5px] text-[#1a1a18] ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
>
{indexCms.headline}
</h1>
<p
className="m-0 mx-auto mt-5 max-w-[720px] font-['Raleway',sans-serif] text-[16px] font-medium leading-[33.022px] not-italic"
style={{color: MUTED}}
>
{indexCms.description}
</p>
</motion.div>
</div>
</header>
{activeCategory === 'All' && (
<section className="w-full pt-10 lg:pt-[60px]">
<div className="mx-auto max-w-[1710px] px-6 sm:px-10 lg:px-16 2xl:px-[206px]">
<div className="mx-auto grid max-w-[1298px] grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-[34px]">
{featured.map((post, i) => (
<motion.article
key={post.id}
animate={{opacity: 1, y: 0}}
initial={{opacity: 0, y: 24}}
transition={{duration: 0.55, delay: i * 0.08}}
>
<Link
className="group block text-inherit no-underline"
prefetch="intent"
to={`/journal/${post.id}`}
>
<div
className="relative aspect-[8/5] w-full overflow-hidden sm:aspect-[16/10]"
style={{background: HERO_BG}}
>
<JournalPostImage
alt=""
aspectRatio="8/5"
className="transition-transform duration-700 group-hover:scale-[1.03]"
sizes="(min-width: 1024px) 50vw, 100vw"
url={post.image}
/>
</div>
<div className="mt-[21px] flex flex-wrap items-center gap-[17px]">
<span
className="inline-flex shrink-0 items-center font-['Jost',sans-serif] text-[9.35px] font-medium uppercase leading-[14.025px] tracking-[1.4025px] not-italic"
style={{
background: CHARCOAL,
color: CREAM_TEXT,
padding: '4.25px 12.75px',
}}
>
{post.category}
</span>
<div className="flex items-center gap-[6.375px]">
<Image
alt=""
className="size-[11px] shrink-0"
height={11}
src={iconClockFeatured}
width={11}
/>
<span
className="font-['Jost',sans-serif] text-[14px] font-normal leading-[16.575px] not-italic"
style={{color: MUTED_MID}}
>
{post.readTime}
</span>
</div>
</div>
<h3
className="m-0 mt-[13px] font-['Cormorant_Garamond',serif] text-[32px] font-normal leading-[40.375px] tracking-[-0.4845px] not-italic"
style={{color: CHARCOAL}}
>
{post.title}
</h3>
<p
className="m-0 mt-2 max-w-[613px] font-['Cormorant_Garamond',serif] text-[18px] font-medium leading-normal not-italic"
style={{color: CHARCOAL}}
>
{post.excerpt}
</p>
</Link>
</motion.article>
))}
</div>
</div>
</section>
)}
<div className="mx-auto max-w-[1710px] px-6 pt-10 sm:px-10 lg:px-16 lg:pt-14 2xl:px-[206px]">
<div
className="mx-auto flex max-w-[1298px] flex-wrap items-center gap-[8.5px] pb-0"
style={{
borderBottom: `1px solid ${BORDER_HAIRLINE}`,
minHeight: '69.805px',
}}
>
{blogCategories.map((cat) => {
const active = activeCategory === cat;
return (
<button
key={cat}
className="cursor-pointer transition-colors duration-200"
style={{
background: active ? CHARCOAL : 'transparent',
color: active ? CREAM_TEXT : MUTED_MID,
fontFamily: "'Jost', sans-serif",
fontSize: '10.54px',
fontWeight: 500,
lineHeight: '15.81px',
letterSpacing: '1.2648px',
textTransform: 'uppercase',
border: `1px solid ${BORDER_BTN}`,
padding: '9.5px 18px',
}}
type="button"
onClick={() => setActiveCategory(cat)}
>
{cat}
</button>
);
})}
</div>
</div>
<section className="mx-auto max-w-[1710px] px-6 pb-20 pt-10 sm:px-10 lg:px-16 lg:pt-12 2xl:px-[206px]">
<div className="mx-auto grid w-full max-w-[1298px] grid-cols-1 gap-x-[34px] gap-y-10 md:grid-cols-3 md:gap-y-[34px]">
{(activeCategory === 'All' ? rest : filtered).map((post, i) => (
<motion.article
key={post.id}
className="min-w-0"
initial={{opacity: 0, y: 20}}
transition={{duration: 0.5, delay: Math.min(i * 0.06, 0.36)}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
<Link
className="group block text-inherit no-underline"
prefetch="intent"
to={`/journal/${post.id}`}
>
<div
className="relative aspect-[4/3] w-full overflow-hidden"
style={{background: HERO_BG}}
>
<JournalPostImage
alt=""
aspectRatio="4/3"
className="transition-transform duration-700 group-hover:scale-[1.03]"
sizes="(min-width: 768px) 33vw, 100vw"
url={post.image}
/>
</div>
<div className="mt-[17px] flex flex-wrap items-center gap-[12.75px]">
<span
className="font-['Jost',sans-serif] text-[12px] font-medium uppercase leading-[14.025px] tracking-[1.4025px] not-italic"
style={{color: ACCENT}}
>
{post.category}
</span>
<span
className="select-none font-['Raleway',sans-serif] text-[10.2px] font-normal leading-[15.3px] not-italic"
style={{color: DOT_MUTED}}
>
·
</span>
<div className="flex items-center gap-[4.25px]">
<Image
alt=""
className="size-[10px] shrink-0"
height={10}
src={iconClockGrid}
width={10}
/>
<span
className="font-['Jost',sans-serif] text-[14px] font-normal leading-[16.575px] not-italic"
style={{color: MUTED_MID}}
>
{post.readTime}
</span>
</div>
</div>
<h3
className="m-0 mt-[13px] font-['Cormorant_Garamond',serif] text-[24px] font-medium leading-[24.48px] tracking-[-0.204px] not-italic"
style={{color: CHARCOAL}}
>
{post.title}
</h3>
<p
className="m-0 mt-2 min-h-[3.5rem] font-['Cormorant_Garamond',serif] text-[18px] font-medium leading-normal not-italic"
style={{color: CHARCOAL}}
>
{gridCardTeaser(post)}
</p>
<div className="mt-6 flex items-center justify-between">
<span
className="font-['Jost',sans-serif] text-[14px] font-normal leading-[16.575px] not-italic"
style={{color: MUTED_MID}}
>
{post.date}
</span>
<span
className="inline-flex shrink-0 items-center gap-1.5 font-['Jost',sans-serif] text-[10.54px] font-medium uppercase leading-[15.81px] tracking-[1.054px] not-italic"
style={{color: CHARCOAL}}
>
Read More
<Image
alt=""
className="size-[11px] shrink-0"
height={11}
src={iconReadArrow}
width={11}
/>
</span>
</div>
</Link>
</motion.article>
))}
</div>
</section>
<Newsletter cms={newsletterCms} />
</div>
);
}

View File

@ -0,0 +1,363 @@
import {Image} from '@shopify/hydrogen';
import {Link} from 'react-router';
import {motion} from 'motion/react';
import {ArrowLeft, Clock, Tag} from 'lucide-react';
import {MOCK_ABIIR_JOURNAL_POSTS} from '~/lib/journal';
import type {AbiirJournalPostCms} from '~/lib/journal';
import {abiirJournalImageIsRemote} from '~/lib/journal';
import {PAGE_CONTENT_MAX_WIDTH_CLASS} from '~/lib/pageTitle';
import {PAGE_TITLE_TYPOGRAPHY_CLASS} from '~/lib/pageTitle';
// ─── Sanity: GROQ projection (single `journalPost` document) ─────────────────
/**
* GROQ field projection for one journal article. Reused by `buildJournalPostGroq`.
* Sanity schema: `journalPost` in `Hydrogen/studio` (title, slug, content, image, etc.).
*/
export const JOURNAL_POST_GROQ_PROJECTION = /* groq */ `{
"slug": slug.current,
title,
subtitle,
excerpt,
cardExcerpt,
content,
author,
authorRole,
date,
readTime,
category,
featured,
tags,
image{
alt,
asset->{
_id,
url,
metadata{dimensions{width,height}}
}
}
}` as const;
/**
* Builds a **parameterized** GROQ query for a single post by `slug` (JSON-stringified, safe for GROQ).
* Use in the route `loader` with `context.sanity.loadQuery(buildJournalPostGroq(params.slug))`.
*/
export function buildJournalPostGroq(slug: string): string {
const safe = JSON.stringify(slug);
return `*[_type == "journalPost" && slug.current == ${safe}][0]${JOURNAL_POST_GROQ_PROJECTION}`;
}
/**
* Static **example** query (illustrates shape only). Prefer `buildJournalPostGroq` in production.
*/
export const JOURNAL_POST_PAGE_GROQ_EXAMPLE = `*[_type == "journalPost" && slug.current == "ancient-art-ayurvedic-skincare"][0]${JOURNAL_POST_GROQ_PROJECTION}` as const;
// ─── Mock: visual / Storybook (matches `React` blog post shape) ─────────────
function buildMockRelated(post: AbiirJournalPostCms): AbiirJournalPostCms[] {
return MOCK_ABIIR_JOURNAL_POSTS.filter(
(p) => p.id !== post.id && p.category === post.category,
).slice(0, 3);
}
/** `JournalPostPage` with first seed post + related — for local preview or design QA. */
export const MOCK_JOURNAL_POST_PAGE_PROPS: {
post: AbiirJournalPostCms;
relatedPosts: AbiirJournalPostCms[];
} = {
post: MOCK_ABIIR_JOURNAL_POSTS[0]!,
relatedPosts: buildMockRelated(MOCK_ABIIR_JOURNAL_POSTS[0]!),
};
// ─── Images: Shopify Hydrogen <Image /> (local Vite + Sanity CDN) ───────────
function HeroImage({url, alt}: {url: string; alt: string}) {
if (abiirJournalImageIsRemote(url)) {
return (
<Image
alt={alt}
className="h-full w-full object-cover brightness-[0.85]"
data={{
url,
altText: alt,
width: 1920,
height: 1080,
}}
height="auto"
loader={({src}) => src ?? ''}
sizes="100vw"
width="100%"
/>
);
}
return (
<Image
alt={alt}
className="h-full w-full object-cover brightness-[0.85]"
height="auto"
sizes="100vw"
src={url}
width="100%"
/>
);
}
function RelatedThumb({url, alt}: {url: string; alt: string}) {
const imgClass =
'block h-full w-full min-h-0 object-cover transition-transform duration-500 group-hover:scale-105';
if (abiirJournalImageIsRemote(url)) {
return (
<Image
alt={alt}
className={imgClass}
data={{
url,
altText: alt,
width: 1200,
height: 800,
}}
height="auto"
loader={({src}) => src ?? ''}
sizes="(min-width: 640px) 33vw, 100vw"
width="100%"
/>
);
}
return (
<Image
alt={alt}
className={imgClass}
height="auto"
sizes="(min-width: 640px) 33vw, 100vw"
src={url}
width="100%"
/>
);
}
// ─── Page: blog detail (1:1 with `React/src/app/pages/JournalPost.tsx`) ─────
export type JournalPostPageProps = {
/** Single article (from GROQ / loader) or mock. */
post: AbiirJournalPostCms;
relatedPosts: AbiirJournalPostCms[];
};
export function JournalPostPage({post, relatedPosts}: JournalPostPageProps) {
const paragraphs = post.content
.split('\n\n')
.map((p) => p.trim())
.filter(Boolean);
return (
<div className="min-h-screen bg-[#F8F4EF]">
{/* Breadcrumb — Figma: px-6 py-4, border 6% black */}
<div className="border-b border-[rgba(26,26,24,0.06)] px-[24px] py-[16px] lg:px-[48px]">
<div
className={`mx-auto flex items-center gap-[8px] ${PAGE_CONTENT_MAX_WIDTH_CLASS}`}
>
<Link
className="flex items-center gap-[6px] font-['Jost',sans-serif] text-[0.65rem] font-normal uppercase leading-none tracking-[0.1em] text-[#7A7068] no-underline transition-opacity hover:opacity-60"
to="/journal"
>
<ArrowLeft
className="shrink-0"
size={11}
strokeWidth={1.5}
/>
Journal
</Link>
<span className="select-none text-[0.65rem] leading-none text-[rgba(26,26,24,0.2)]">
/
</span>
<span className="font-['Jost',sans-serif] text-[0.65rem] font-normal uppercase leading-none tracking-[0.1em] text-[#1A1A18]">
{post.category}
</span>
</div>
</div>
{/* Hero: 55vh, min 360px (reference) */}
<div className="h-[55vh] min-h-[360px] w-full overflow-hidden">
<HeroImage
alt={post.title}
url={post.image}
/>
</div>
<div
className={`mx-auto px-[24px] py-[56px] lg:px-[48px] ${PAGE_CONTENT_MAX_WIDTH_CLASS}`}
>
<div>
{/* Category & meta — gap-4, mb-6 = 16px / 24px */}
<div className="mb-[24px] flex flex-wrap items-center gap-[16px]">
<span className="bg-[#1A1A18] px-[12px] py-[4px] font-['Jost',sans-serif] text-[0.55rem] font-medium uppercase not-italic leading-none tracking-[0.15em] text-[#F8F4EF]">
{post.category}
</span>
<div className="flex items-center gap-[6px]">
<Clock
className="shrink-0 text-[#7A7068]"
size={12}
strokeWidth={1.5}
/>
<span className="font-['Jost',sans-serif] text-[0.65rem] font-light not-italic leading-none text-[#7A7068]">
{post.readTime}
</span>
</div>
<span className="font-['Jost',sans-serif] text-[0.65rem] font-light not-italic leading-none text-[#7A7068]">
{post.date}
</span>
</div>
<motion.h1
className="m-0 mb-[12px] font-['Cormorant_Garamond',serif] text-[2.8rem] font-normal not-italic leading-[1.15] tracking-[-1.19px] text-[#1A1A18]"
initial={{opacity: 0, y: 12}}
transition={{duration: 0.55}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
{post.title}
</motion.h1>
<p className="mb-[32px] font-['Cormorant_Garamond',serif] text-[clamp(1rem,2vw,1.3rem)] font-light italic leading-normal text-[#7A7068]">
{post.subtitle}
</p>
{/* Author — border-bottom, gap-3, padding bottom 8 */}
<div className="mb-[32px] flex items-center gap-[12px] border-b border-[rgba(26,26,24,0.08)] pb-[32px]">
<div className="flex h-[40px] w-[40px] shrink-0 items-center justify-center font-['Cormorant_Garamond',serif] text-[0.9rem] not-italic leading-none text-[#C17D5C] [background-color:#EDE8E0]">
{post.author
.split(' ')
.map((n) => n[0])
.join('')}
</div>
<div>
<p className="font-['Jost',sans-serif] text-[0.75rem] font-medium not-italic leading-none text-[#1A1A18]">
{post.author}
</p>
<p className="mt-[2px] font-['Jost',sans-serif] text-[0.62rem] font-light not-italic leading-none tracking-[0.05em] text-[#7A7068]">
{post.authorRole}
</p>
</div>
</div>
{/* Content — space-y-5 = 20px; body Jost 0.92rem /1.9; Cormorant “bold” lines 1.15rem/1.5 */}
<div className="space-y-5">
{paragraphs.map((para, i) => {
const isBold = para.startsWith('**') && para.includes('**');
if (isBold) {
const boldText = para.replace(/\*\*(.*?)\*\*/g, '$1');
return (
<p
key={i}
className="font-['Cormorant_Garamond',serif] text-[1.15rem] font-medium not-italic leading-[1.5] text-[#1A1A18]"
>
{boldText}
</p>
);
}
return (
<p
key={i}
className="font-['Jost',sans-serif] text-[0.92rem] font-light not-italic leading-[1.9] text-[#4A4845]"
>
{para}
</p>
);
})}
</div>
{/* Tags — border-top, pt-10 mt-10 */}
<div className="mt-[40px] flex flex-wrap gap-[8px] border-t border-[rgba(26,26,24,0.08)] pt-[40px]">
<div className="mr-[8px] flex items-center gap-[8px]">
<Tag
className="shrink-0 text-[#7A7068]"
size={12}
strokeWidth={1.5}
/>
<span className="font-['Jost',sans-serif] text-[0.6rem] font-normal uppercase not-italic leading-none tracking-[0.12em] text-[#7A7068]">
Tags:
</span>
</div>
{post.tags.map((tag) => (
<span
key={tag}
className="border border-[rgba(26,26,24,0.12)] px-[12px] py-[4px] font-['Jost',sans-serif] text-[0.58rem] font-normal uppercase not-italic leading-none tracking-[0.1em] text-[#7A7068]"
>
{tag}
</span>
))}
</div>
</div>
</div>
{/* Continue reading — #EDE8E0, py-16 / lg:py-20, max 1400 */}
{relatedPosts.length > 0 && (
<section className="bg-[#EDE8E0] py-[64px] lg:py-[80px]">
<div className="mx-auto max-w-[1400px] px-[24px] lg:px-[48px]">
<motion.h2
className={`m-0 mb-[40px] w-full text-[#1A1A18] ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
initial={{opacity: 0, y: 12}}
transition={{duration: 0.55}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
Continue Reading
</motion.h2>
<div className="grid grid-cols-1 gap-[32px] sm:grid-cols-3">
{relatedPosts.map((relPost, i) => (
<motion.div
key={relPost.id}
className="min-w-0"
initial={{opacity: 0, y: 20}}
transition={{duration: 0.5, delay: i * 0.1}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
<Link
className="group block no-underline"
prefetch="intent"
to={`/journal/${relPost.id}`}
>
<div className="relative mb-[16px] aspect-[4/3] w-full overflow-hidden [background-color:#F8F4EF]">
<RelatedThumb
alt={relPost.title}
url={relPost.image}
/>
</div>
<span className="font-['Jost',sans-serif] text-[0.55rem] font-medium uppercase not-italic leading-none tracking-[0.12em] text-[#C17D5C]">
{relPost.category}
</span>
<h3 className="mt-1 font-['Cormorant_Garamond',serif] text-[1.15rem] font-normal not-italic leading-[1.3] text-[#1A1A18] transition-opacity group-hover:opacity-70">
{relPost.title}
</h3>
<div className="mt-3 flex items-center gap-1">
<span className="font-['Jost',sans-serif] text-[0.62rem] font-normal uppercase not-italic leading-none tracking-[0.1em] text-[#7A7068] transition-colors group-hover:text-[#1A1A18]">
Read →
</span>
</div>
</Link>
</motion.div>
))}
</div>
</div>
</section>
)}
</div>
);
}
export function JournalPostNotFound() {
return (
<div className="flex min-h-screen items-center justify-center bg-[#F8F4EF]">
<div className="text-center">
<p className="font-['Cormorant_Garamond',serif] text-[1.5rem] not-italic text-[#7A7068]">
Article not found
</p>
<Link
className="mt-4 inline-block border-b border-[#C17D5C] font-['Jost',sans-serif] text-[0.7rem] font-normal uppercase leading-none tracking-[0.15em] text-[#C17D5C] no-underline"
to="/journal"
>
Back to Journal
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,383 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
import {Image} from '@shopify/hydrogen';
import {motion} from 'motion/react';
import {Instagram} from 'lucide-react';
import communityVertical2Src from '~/assets/instagram/vertical2.png';
import communityVertical3Src from '~/assets/instagram/vertical3.png';
import type {LifestyleGridSectionCms, LifestyleGridTileCms} from '~/lib/lifestyleGrid';
import {MOCK_LIFESTYLE_GRID_SECTION_CMS} from '~/lib/lifestyleGrid';
import {PAGE_TITLE_TYPOGRAPHY_CLASS} from '~/lib/pageTitle';
import {useSwipeNavigate} from '~/lib/useSwipeNavigate';
/**
* GROQ: `home` singleton → lifestyle / Instagram grid.
* Homepage loader merges in live IG for the first slot when 3 tiles are available (see
* `~/lib/instagramFeed.ts`). Slots 2–3 always use `vertical2.png` and `vertical3.png`.
*/
export const LIFESTYLE_GRID_SECTION_GROQ = `*[_type == "home" && _id == "home"][0].lifestyleGridSection{
eyebrow,
headline,
instagramUrl,
followLabel,
sectionBackground,
footerPrefix,
hashtag,
footerSuffix,
hashtagColor,
tiles[]{
caption,
tall,
externalImageUrl,
image{
alt,
asset->{
_id,
url,
metadata{dimensions{width,height}}
}
}
}
}` as const;
export {MOCK_LIFESTYLE_GRID_SECTION_CMS};
export type {LifestyleGridSectionCms};
function isRemoteImageUrl(url: string) {
return /^https?:\/\//i.test(url);
}
function LifestyleGridImage({
tile,
sizes,
}: {
tile: LifestyleGridTileCms;
sizes: string;
}) {
const className =
'h-full w-full object-cover transition-transform duration-700 group-hover:scale-105';
if (isRemoteImageUrl(tile.imageUrl)) {
const w = tile.width ?? 800;
const h = tile.height ?? 1200;
return (
<Image
alt={tile.alt}
className={className}
data={{
url: tile.imageUrl,
altText: tile.alt,
width: w,
height: h,
}}
loader={({src}) => src ?? ''}
sizes={sizes}
/>
);
}
return (
<Image
alt={tile.alt}
className={className}
height={tile.height ?? 1600}
sizes={sizes}
src={tile.imageUrl}
width={tile.width ?? 1200}
/>
);
}
const OVERLAY_BG =
'linear-gradient(to top, rgba(26,26,24,0.6) 0%, transparent 60%)';
const LIFESTYLE_MOBILE_SLIDE_MS = 2000;
const LIFESTYLE_DOT_ACTIVE = '#5D0B24';
const LIFESTYLE_DOT_INACTIVE = 'rgba(26,26,24,0.22)';
function LifestyleGridMobileSlider({tiles}: {tiles: LifestyleGridTileCms[]}) {
const [active, setActive] = useState(0);
const tilesKey = useMemo(
() => tiles.map((t) => `${t.imageUrl}\0${t.caption}`).join('|'),
[tiles],
);
useEffect(() => {
setActive(0);
}, [tilesKey]);
useEffect(() => {
if (tiles.length <= 1) return;
if (typeof window === 'undefined') return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const id = window.setInterval(() => {
setActive((i) => (i + 1) % tiles.length);
}, LIFESTYLE_MOBILE_SLIDE_MS);
return () => window.clearInterval(id);
}, [tiles.length]);
const goPrev = useCallback(() => {
if (tiles.length <= 1) return;
setActive((i) => (i - 1 + tiles.length) % tiles.length);
}, [tiles.length]);
const goNext = useCallback(() => {
if (tiles.length <= 1) return;
setActive((i) => (i + 1) % tiles.length);
}, [tiles.length]);
const swipe = useSwipeNavigate(goPrev, goNext);
return (
<div
className="w-full overflow-hidden lg:hidden"
aria-roledescription="carousel"
aria-label="Community gallery"
{...(tiles.length > 1 ? swipe : {})}
>
<div
className="flex h-[min(520px,88vw)] transition-transform duration-500 ease-out motion-reduce:transition-none"
style={{transform: `translateX(-${active * 100}%)`}}
>
{tiles.map((tile, index) => (
<div
key={`${tile.imageUrl}-${index}`}
className="h-[min(520px,88vw)] w-full shrink-0 basis-full px-0.5"
aria-hidden={index !== active}
>
<TileCard
delay={index * 0.06}
layout="slide"
sizes="100vw"
smallOverlay
tile={tile}
/>
</div>
))}
</div>
<div className="mt-5 flex justify-center gap-1.5" aria-hidden>
{tiles.map((_, i) => (
<span
key={i}
role="presentation"
className="h-1.5 rounded-full transition-all duration-300"
style={{
width: i === active ? 22 : 6,
background: i === active ? LIFESTYLE_DOT_ACTIVE : LIFESTYLE_DOT_INACTIVE,
}}
/>
))}
</div>
</div>
);
}
type TileCardLayout = 'equalRow' | 'slide';
function TileCard({
tile,
sizes,
layout = 'slide',
smallOverlay,
delay,
}: {
tile: LifestyleGridTileCms;
sizes: string;
/** `equalRow`: one of three equal columns (desktop). `slide`: full-width slide (mobile). */
layout?: TileCardLayout;
smallOverlay?: boolean;
delay: number;
}) {
const overlay = (
<div
className={`absolute inset-0 flex items-end opacity-0 transition-opacity duration-300 group-hover:opacity-100 ${smallOverlay ? 'p-3' : 'p-4'}`}
style={{background: OVERLAY_BG}}
>
<div
className={`flex items-center ${smallOverlay ? 'gap-1.5' : 'gap-2'}`}
>
<Instagram
aria-hidden
size={smallOverlay ? 10 : 12}
strokeWidth={1.5}
style={{color: '#F8F4EF'}}
/>
<span
className={`font-['Jost',sans-serif] not-italic ${smallOverlay ? 'text-[0.6rem]' : 'text-[0.65rem]'}`}
style={{color: '#F8F4EF'}}
>
{tile.caption}
</span>
</div>
</div>
);
const inner = (
<>
<LifestyleGridImage sizes={sizes} tile={tile} />
{overlay}
</>
);
const className =
layout === 'equalRow'
? 'group relative block h-full min-h-0 min-w-0 w-full flex-1 basis-0 cursor-pointer overflow-hidden'
: 'group relative block h-full min-h-0 w-full cursor-pointer overflow-hidden';
const motionProps = {
initial: false,
viewport: {once: true},
whileInView: {opacity: 1, scale: 1},
transition: {duration: 0.5, delay},
className,
};
if (tile.postUrl) {
return (
<motion.a
{...motionProps}
href={tile.postUrl}
rel="noopener noreferrer"
target="_blank"
>
{inner}
</motion.a>
);
}
return <motion.div {...motionProps}>{inner}</motion.div>;
}
export type LifestyleGridProps = {
cms: LifestyleGridSectionCms;
};
/**
* `React/src/app/components/LifestyleGrid.tsx` — Tailwind + Hydrogen `Image`.
*/
/** Slot 1 from CMS/IG; slots 2–3 always use bundled vertical art. */
function withStaticSecondThirdTiles(
tiles: LifestyleGridTileCms[],
): LifestyleGridTileCms[] {
if (tiles.length < 3) return tiles;
const [a, b, c] = tiles;
return [
a,
{
...b,
imageUrl: communityVertical2Src,
width: 455,
height: 580,
alt: b.alt || 'ABiiR Skincare — community',
},
{
...c,
imageUrl: communityVertical3Src,
width: 455,
height: 580,
alt: c.alt || 'ABiiR Skincare — community',
},
];
}
export function LifestyleGrid({cms}: LifestyleGridProps) {
const tiles = useMemo(() => {
if (cms.tiles.length < 3) return [];
return withStaticSecondThirdTiles(cms.tiles);
}, [cms.tiles]);
if (cms.tiles.length < 3 || tiles.length < 3) {
return null;
}
const [first, second, third] = tiles;
const sectionBg = cms.sectionBackground;
const burgundy = '#5D0B24';
const charcoal = '#1A1A18';
return (
<section className="w-full py-20 lg:py-28" style={{background: sectionBg}}>
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
<div className="mb-4 flex flex-col justify-between gap-3 sm:flex-row sm:items-end sm:mb-8 lg:mb-10">
<div>
<motion.span
initial={false}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
className="mb-2 inline-block font-['Jost',sans-serif] text-[0.65rem] font-medium uppercase not-italic sm:mb-3"
style={{letterSpacing: '0.25em', color: burgundy}}
>
{cms.eyebrow}
</motion.span>
<h2
className={`m-0 ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
style={{color: charcoal}}
>
<a
href={cms.instagramUrl}
target="_blank"
rel="noopener noreferrer"
className="text-inherit no-underline transition-opacity hover:opacity-70"
>
{cms.headline}
</a>
</h2>
</div>
<a
href={cms.instagramUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 font-['Jost',sans-serif] text-[0.65rem] font-medium uppercase no-underline transition-opacity hover:opacity-60 not-italic"
style={{letterSpacing: '0.12em', color: charcoal}}
>
<Instagram size={14} strokeWidth={1.5} aria-hidden />
{cms.followLabel}
</a>
</div>
<LifestyleGridMobileSlider tiles={tiles} />
<div className="hidden h-[480px] min-h-0 w-full gap-3 lg:flex lg:h-[580px]">
<TileCard
delay={0}
layout="equalRow"
sizes="(min-width: 1024px) 30vw, 100vw"
smallOverlay
tile={first}
/>
<TileCard
delay={0.08}
layout="equalRow"
sizes="(min-width: 1024px) 30vw, 100vw"
smallOverlay
tile={second}
/>
<TileCard
delay={0.16}
layout="equalRow"
sizes="(min-width: 1024px) 30vw, 100vw"
smallOverlay
tile={third}
/>
</div>
<div className="mt-8 text-center">
<p
className="m-0 font-['Jost',sans-serif] font-light not-italic"
style={{
color: '#7A7068',
fontSize: '0.78rem',
fontWeight: 300,
}}
>
{cms.footerPrefix}
<strong style={{color: cms.hashtagColor, fontWeight: 500}}>
{cms.hashtag}
</strong>
{cms.footerSuffix}
</p>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,27 @@
import {useEffect, useRef} from 'react';
import {useLocation} from 'react-router';
import {loadMetaPixel} from '~/lib/loadMetaPixel';
/**
* Loads Meta Pixel after hydration and fires `PageView` on SPA navigations.
*/
export function MetaPixelPageView() {
const location = useLocation();
const isInitialLoad = useRef(true);
useEffect(() => {
loadMetaPixel();
}, []);
useEffect(() => {
if (isInitialLoad.current) {
isInitialLoad.current = false;
return;
}
if (typeof window.fbq === 'function') {
window.fbq('track', 'PageView');
}
}, [location.pathname, location.search]);
return null;
}

View File

@ -0,0 +1,20 @@
export function MockShopNotice() {
return (
<section
className="mock-shop-notice"
aria-labelledby="mock-shop-notice-heading"
>
<div className="inner">
<h2 id="mock-shop-notice-heading">Welcome to Hydrogen!</h2>
<p>
You&rsquo;re seeing mocked products because no store is connected to
this project yet.
</p>
<p>
Link a store by running <code>npx shopify hydrogen link</code> in your
terminal.
</p>
</div>
</section>
);
}

View File

@ -0,0 +1,282 @@
import {useEffect, useState, type FormEvent} from 'react';
import {useFetcher} from 'react-router';
import {motion} from 'motion/react';
import {ArrowRight, Sparkles} from 'lucide-react';
import {PAGE_TITLE_TYPOGRAPHY_CLASS} from '~/lib/pageTitle';
import type {NewsletterSectionCms} from '~/lib/newsletter';
import {MOCK_NEWSLETTER_SECTION_CMS} from '~/lib/newsletter';
/**
* GROQ: `home` singleton → Glow Club newsletter.
* Use with `context.sanity.loadQuery(NEWSLETTER_SECTION_GROQ)` or `loadNewsletterFromSanity`.
*
* Reference has no `<img>`; decorative orbs are CSS-only (no Shopify `Image` needed).
*/
export const NEWSLETTER_SECTION_GROQ = `*[_type == "home" && _id == "home"][0].newsletterSection{
eyebrow,
headlinePlain,
headlineItalic,
body,
emailPlaceholder,
submitButtonLabel,
successTitle,
successSubtitle,
perks
}` as const;
export {MOCK_NEWSLETTER_SECTION_CMS};
export type {NewsletterSectionCms};
/**
* Design tokens match `React/src/app/components/Newsletter.tsx` (pixel-aligned Tailwind).
*/
const BURGUNDY = '#5d0b24';
const CREAM = '#faf8f3';
const CREAM_SOFT = '#f8f4ef';
const GOLD = '#b39763';
const CHARCOAL = '#1a1a1a';
const NEWSLETTER_SUBSCRIBE_ACTION = '/api/newsletter-subscribe';
type SubscribeResponse =
| {ok: true; alreadyExists?: boolean}
| {ok: false; error: string};
function subscribeErrorMessage(code: string): string {
switch (code) {
case 'invalid_email':
return 'Please enter a valid email address.';
case 'not_configured':
return 'Newsletter signup is temporarily unavailable.';
case 'service_unavailable':
case 'shopify_error':
default:
return 'Something went wrong. Please try again in a moment.';
}
}
export type NewsletterProps = {
cms: NewsletterSectionCms;
};
export function Newsletter({cms}: NewsletterProps) {
const fetcher = useFetcher<SubscribeResponse>();
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (fetcher.state !== 'idle' || !fetcher.data) return;
if (fetcher.data.ok) {
setSubmitted(true);
}
}, [fetcher.state, fetcher.data]);
function handleSubmit(e: FormEvent) {
e.preventDefault();
const trimmed = email.trim();
if (!trimmed) return;
fetcher.submit({email: trimmed}, {method: 'POST', action: NEWSLETTER_SUBSCRIBE_ACTION});
}
const submitError =
fetcher.state === 'idle' &&
fetcher.data &&
!fetcher.data.ok &&
'error' in fetcher.data ?
subscribeErrorMessage(fetcher.data.error)
: null;
const isSubmitting = fetcher.state !== 'idle';
return (
<section
className="relative w-full overflow-hidden"
style={{background: BURGUNDY}}
>
<div
aria-hidden
className="pointer-events-none absolute rounded-full bg-[#b39763] opacity-[0.05]"
style={{
width: 400,
height: 400,
left: -85,
top: -85,
}}
/>
<div
aria-hidden
className="pointer-events-none absolute rounded-full bg-[#faf8f3] opacity-[0.05]"
style={{
width: 350,
height: 350,
right: -85,
bottom: -85,
}}
/>
<div className="relative z-10 mx-auto max-w-[1298px] px-6 pb-16 pt-16 sm:px-8 sm:pb-20 sm:pt-20 lg:pb-[119px] lg:pt-[119px]">
<motion.div
initial={false}
transition={{duration: 0.65}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
className="flex flex-col items-center gap-8 text-center lg:gap-[30px]"
>
<div className="flex shrink-0 items-center gap-4">
<div
className="flex h-[52px] w-[52px] shrink-0 items-center justify-center"
style={{
background: 'rgba(184, 160, 66, 0.15)',
color: GOLD,
}}
>
<Sparkles aria-hidden size={20} strokeWidth={1.2} />
</div>
<p
className="m-0 font-['Raleway',sans-serif] text-[12px] font-semibold uppercase not-italic leading-[18.36px] tracking-[3.4272px]"
style={{color: GOLD}}
>
{cms.eyebrow}
</p>
</div>
{/*
Headline + body + form share one grid column. The form uses min-width in vw so the column
can grow up to 649px even when the headline is narrower (100% alone would stay headline-wide).
*/}
<div className="mx-auto inline-grid w-max min-w-0 max-w-full grid-cols-1 justify-items-stretch gap-8 text-center lg:gap-[30px]">
<h2
className={`m-0 lg:whitespace-nowrap ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
style={{color: CREAM}}
>
{cms.headlinePlain}{' '}
<span
className="font-['Cormorant_Garamond',serif] font-light italic"
style={{fontWeight: 300, color: GOLD}}
>
{cms.headlineItalic}
</span>
</h2>
<p
className="m-0 w-full max-w-[623px] justify-self-center font-['Raleway',sans-serif] text-[16px] font-medium not-italic leading-[26.18px]"
style={{color: CREAM_SOFT}}
>
{cms.body}
</p>
{!submitted ? (
<form
className="newsletter-form flex w-full min-w-0 flex-col flex-wrap justify-self-center gap-0 overflow-visible sm:min-h-[61px] sm:flex-row sm:items-start"
onSubmit={handleSubmit}
style={{
maxWidth: 649,
width: '100%',
// Inline so layout never depends on Tailwind arbitrary min() generation
minWidth: 'min(calc(100vw - 3rem), 649px)',
}}
>
<input
required
className="newsletter-email box-border w-full min-w-0 flex-1 border border-solid border-[rgba(248,244,239,0.12)] outline-none placeholder:text-[rgba(248,244,239,0.5)] max-sm:border-b-0 sm:border-r-0 sm:py-0"
onChange={(e) => setEmail(e.target.value)}
placeholder={cms.emailPlaceholder}
style={{
padding: '17px 21.25px',
background: 'rgba(248, 244, 239, 0.07)',
color: CREAM_SOFT,
fontFamily: "'Raleway', sans-serif",
fontSize: '15.64px',
fontWeight: 300,
height: 61,
minHeight: 61,
boxSizing: 'border-box',
}}
type="email"
value={email}
/>
<button
className="newsletter-submit group relative box-border flex w-full shrink-0 cursor-pointer items-center justify-center gap-2 border border-solid border-[rgba(248,244,239,0.12)] transition-opacity duration-200 hover:opacity-[0.92] max-sm:border-t-0 sm:w-[172px] sm:border-l-0 sm:py-0 disabled:cursor-not-allowed disabled:opacity-60"
style={{
background: GOLD,
color: CHARCOAL,
fontFamily: "'Raleway', sans-serif",
fontSize: '12.75px',
fontWeight: 500,
letterSpacing: '2.295px',
textTransform: 'uppercase',
height: 59,
minHeight: 59,
marginTop: 5,
boxSizing: 'border-box',
}}
disabled={isSubmitting}
type="submit"
>
{isSubmitting ? '…' : cms.submitButtonLabel}
<ArrowRight
aria-hidden
className="transition-transform group-hover:translate-x-0.5"
size={13}
strokeWidth={2}
/>
</button>
{submitError ?
<p
className="m-0 w-full basis-full text-left font-['Raleway',sans-serif] text-[0.85rem] font-medium"
role="alert"
style={{color: '#f0c4c4'}}
>
{submitError}
</p>
: null}
</form>
) : (
<motion.div
animate={{opacity: 1, scale: 1}}
className="box-border w-full px-8 py-6"
initial={false}
style={{
background: 'rgba(184, 160, 66, 0.15)',
border: '1px solid rgba(179, 151, 99, 0.35)',
}}
>
<p
className="m-0 font-['Cormorant_Garamond',serif] text-[1.4rem] font-normal not-italic"
style={{
fontStyle: 'italic',
fontWeight: 400,
color: CREAM,
}}
>
{cms.successTitle} <span aria-hidden>✨</span>
</p>
<p
className="m-0 mt-1 font-['Raleway',sans-serif] text-[0.85rem] font-medium not-italic"
style={{color: GOLD}}
>
{cms.successSubtitle}
</p>
</motion.div>
)}
</div>
<div
className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-2 font-['Raleway',sans-serif] text-[12.24px] font-light not-italic leading-[18.36px] tracking-[0.7344px] sm:gap-x-8"
>
{cms.perks.map((perk, i) => (
<span
key={`${perk}-${i}`}
className="inline-flex items-center gap-1 whitespace-nowrap"
>
<span aria-hidden style={{color: GOLD}}>
·
</span>
<span style={{color: CREAM}}>{perk}</span>
</span>
))}
</div>
</motion.div>
</div>
</section>
);
}

View File

@ -0,0 +1,223 @@
import {useOptimisticCart} from '@shopify/hydrogen';
import {Await, Link} from 'react-router';
import {Suspense, useId} from 'react';
import type {
CartApiQueryFragment,
FooterQuery,
HeaderQuery,
} from 'storefrontapi.generated';
import type {CustomerFragment} from 'customer-accountapi.generated';
import type {QueryResponseInitial} from '@sanity/react-loader';
import type {AbiirHeaderCms} from '~/components/Header';
import type {AbiirFooterCms} from '~/lib/footer';
import {Aside} from '~/components/Aside';
import {Footer} from '~/components/Footer';
import {Header} from '~/components/Header';
import {WishlistServerSync} from '~/components/WishlistServerSync';
import {CartMain} from '~/components/CartMain';
import {
SEARCH_ENDPOINT,
SearchFormPredictive,
} from '~/components/SearchFormPredictive';
import {SearchResultsPredictive} from '~/components/SearchResultsPredictive';
interface PageLayoutProps {
cart: CartApiQueryFragment | null;
footer: FooterQuery | null;
footerCms: AbiirFooterCms;
header: HeaderQuery;
headerCms: AbiirHeaderCms;
headerSanityInitial: QueryResponseInitial<AbiirHeaderCms | null>;
isLoggedIn: boolean;
accountCustomer: CustomerFragment | null;
publicStoreDomain: string;
children?: React.ReactNode;
}
export function PageLayout({
cart,
children = null,
footer,
footerCms,
header,
headerCms,
headerSanityInitial,
isLoggedIn,
accountCustomer,
publicStoreDomain,
}: PageLayoutProps) {
return (
<Aside.Provider>
<CartAside cart={cart} />
<SearchAside />
<WishlistServerSync isLoggedIn={isLoggedIn} />
{header && (
<Header
header={header}
headerCms={headerCms}
headerSanityInitial={headerSanityInitial}
cart={cart}
isLoggedIn={isLoggedIn}
accountCustomer={accountCustomer}
publicStoreDomain={publicStoreDomain}
/>
)}
<main>{children}</main>
<Footer
footer={footer}
footerCms={footerCms}
header={header}
headerCms={headerCms}
publicStoreDomain={publicStoreDomain}
/>
</Aside.Provider>
);
}
function CartAsideHeading({cart}: {cart: PageLayoutProps['cart']}) {
const optimistic = useOptimisticCart(cart);
const n = optimistic?.totalQuantity ?? 0;
return (
<span className="cart-aside-heading">
<svg
className="cart-aside-heading__bag"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<path
d="M6 8V7a6 6 0 1 1 12 0v1h1.5A1.5 1.5 0 0 1 21 9.5v9A2.5 2.5 0 0 1 18.5 21h-13A2.5 2.5 0 0 1 3 18.5v-9A1.5 1.5 0 0 1 4.5 8H6Zm2 0h8V7a4 4 0 1 0-8 0v1Z"
fill="currentColor"
/>
</svg>
<span className="cart-aside-heading__title">Your Bag ({n})</span>
</span>
);
}
function CartAside({cart}: {cart: PageLayoutProps['cart']}) {
return (
<Aside type="cart" heading={<CartAsideHeading cart={cart} />}>
<CartMain cart={cart} layout="aside" />
</Aside>
);
}
function SearchAsideHeading() {
return (
<span className="cart-aside-heading">
<svg
className="cart-aside-heading__bag"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="10.5" cy="10.5" r="6.5" />
<path d="M16 16l3.5 3.5" />
</svg>
<span className="cart-aside-heading__title">Search</span>
</span>
);
}
function SearchAside() {
const queriesDatalistId = useId();
return (
<Aside type="search" heading={<SearchAsideHeading />}>
<div className="search-aside">
<SearchFormPredictive className="search-aside-form predictive-search-form">
{({fetchResults, goToSearch, inputRef}) => (
<div className="search-aside__fields">
<input
name="q"
onChange={fetchResults}
onFocus={fetchResults}
placeholder="Search products, pages…"
ref={inputRef}
type="search"
list={queriesDatalistId}
className="search-aside__input"
autoComplete="off"
aria-label="Search"
/>
<button
type="button"
className="search-aside__submit"
onClick={goToSearch}
>
Search
</button>
</div>
)}
</SearchFormPredictive>
<div className="search-aside__scroll predictive-search">
<SearchResultsPredictive>
{({items, total, term, state, closeSearch}) => {
const {articles, collections, pages, products, queries} = items;
if (state === 'loading' && term.current) {
return <p className="search-aside__status">Searching…</p>;
}
if (!total) {
return <SearchResultsPredictive.Empty term={term} />;
}
return (
<div className="search-aside__results">
<SearchResultsPredictive.Queries
queries={queries}
queriesDatalistId={queriesDatalistId}
/>
<SearchResultsPredictive.Products
products={products}
closeSearch={closeSearch}
term={term}
/>
<SearchResultsPredictive.Collections
collections={collections}
closeSearch={closeSearch}
term={term}
/>
<SearchResultsPredictive.Pages
pages={pages}
closeSearch={closeSearch}
term={term}
/>
<SearchResultsPredictive.Articles
articles={articles}
closeSearch={closeSearch}
term={term}
/>
{term.current && total ?
<Link
className="search-aside__view-all"
onClick={closeSearch}
to={`${SEARCH_ENDPOINT}?q=${encodeURIComponent(term.current)}`}
>
<span>View all results for </span>
<q>{term.current}</q>
<span aria-hidden> →</span>
</Link>
: null}
</div>
);
}}
</SearchResultsPredictive>
</div>
</div>
</Aside>
);
}

View File

@ -0,0 +1,31 @@
import type {HTMLAttributes} from 'react';
import {
PAGE_TITLE_DEFAULT_TEXT_CLASS,
PAGE_TITLE_TYPOGRAPHY_CLASS,
} from '~/lib/pageTitle';
type PageTitleProps = {
as?: 'h1' | 'h2';
className?: string;
children: React.ReactNode;
} & HTMLAttributes<HTMLHeadingElement>;
/**
* Main page / prime section title — typography matches homepage `Testimonials` heading.
* Pass extra `className` for layout (e.g. `text-center max-w-[1298px]`). Override default charcoal with `className` or `style` when needed (hero on image, CMS).
*/
export function PageTitle({
as: Tag = 'h1',
className = '',
children,
...rest
}: PageTitleProps) {
return (
<Tag
className={`m-0 ${PAGE_TITLE_TYPOGRAPHY_CLASS} ${PAGE_TITLE_DEFAULT_TEXT_CLASS} ${className}`.trim()}
{...rest}
>
{children}
</Tag>
);
}

View File

@ -0,0 +1,61 @@
import * as React from 'react';
import {Pagination} from '@shopify/hydrogen';
/**
* <PaginatedResourceSection> encapsulates the previous and next pagination behaviors throughout your application.
*/
export function PaginatedResourceSection<NodesType>({
connection,
children,
ariaLabel,
resourcesClassName,
}: {
connection: React.ComponentProps<typeof Pagination<NodesType>>['connection'];
children: React.FunctionComponent<{node: NodesType; index: number}>;
ariaLabel?: string;
resourcesClassName?: string;
}) {
return (
<Pagination connection={connection}>
{({nodes, isLoading, PreviousLink, NextLink}) => {
const resourcesMarkup = nodes.map((node, index) =>
children({node, index}),
);
return (
<div>
<PreviousLink>
{isLoading ? (
'Loading...'
) : (
<span>
<span aria-hidden="true">↑</span> Load previous
</span>
)}
</PreviousLink>
{resourcesClassName ? (
<div
aria-label={ariaLabel}
className={resourcesClassName}
role={ariaLabel ? 'region' : undefined}
>
{resourcesMarkup}
</div>
) : (
resourcesMarkup
)}
<NextLink>
{isLoading ? (
'Loading...'
) : (
<span>
Load more <span aria-hidden="true">↓</span>
</span>
)}
</NextLink>
</div>
);
}}
</Pagination>
);
}

View File

@ -0,0 +1,150 @@
import {Link, useNavigate} from 'react-router';
import {type MappedProductOptions} from '@shopify/hydrogen';
import type {
Maybe,
ProductOptionValueSwatch,
} from '@shopify/hydrogen/storefront-api-types';
import {AddToCartButton} from './AddToCartButton';
import {useAside} from './Aside';
import type {ProductFragment} from 'storefrontapi.generated';
export function ProductForm({
productOptions,
selectedVariant,
}: {
productOptions: MappedProductOptions[];
selectedVariant: ProductFragment['selectedOrFirstAvailableVariant'];
}) {
const navigate = useNavigate();
const {open} = useAside();
return (
<div className="product-form">
{productOptions.map((option) => {
// If there is only a single value in the option values, don't display the option
if (option.optionValues.length === 1) return null;
return (
<div className="product-options" key={option.name}>
<h5>{option.name}</h5>
<div className="product-options-grid">
{option.optionValues.map((value) => {
const {
name,
handle,
variantUriQuery,
selected,
available,
exists,
isDifferentProduct,
swatch,
} = value;
if (isDifferentProduct) {
// SEO
// When the variant is a combined listing child product
// that leads to a different url, we need to render it
// as an anchor tag
return (
<Link
className="product-options-item"
key={option.name + name}
prefetch="intent"
preventScrollReset
replace
to={`/products/${handle}?${variantUriQuery}`}
style={{
border: selected
? '1px solid black'
: '1px solid transparent',
opacity: available ? 1 : 0.3,
}}
>
<ProductOptionSwatch swatch={swatch} name={name} />
</Link>
);
} else {
// SEO
// When the variant is an update to the search param,
// render it as a button with javascript navigating to
// the variant so that SEO bots do not index these as
// duplicated links
return (
<button
type="button"
className={`product-options-item${
exists && !selected ? ' link' : ''
}`}
key={option.name + name}
style={{
border: selected
? '1px solid black'
: '1px solid transparent',
opacity: available ? 1 : 0.3,
}}
disabled={!exists}
onClick={() => {
if (!selected) {
void navigate(`?${variantUriQuery}`, {
replace: true,
preventScrollReset: true,
});
}
}}
>
<ProductOptionSwatch swatch={swatch} name={name} />
</button>
);
}
})}
</div>
<br />
</div>
);
})}
<AddToCartButton
disabled={!selectedVariant || !selectedVariant.availableForSale}
onClick={() => {
open('cart');
}}
lines={
selectedVariant
? [
{
merchandiseId: selectedVariant.id,
quantity: 1,
selectedVariant,
},
]
: []
}
>
{selectedVariant?.availableForSale ? 'Add to cart' : 'Sold out'}
</AddToCartButton>
</div>
);
}
function ProductOptionSwatch({
swatch,
name,
}: {
swatch?: Maybe<ProductOptionValueSwatch> | undefined;
name: string;
}) {
const image = swatch?.image?.previewImage?.url;
const color = swatch?.color;
if (!image && !color) return name;
return (
<div
aria-label={name}
className="product-option-label-swatch"
style={{
backgroundColor: color || 'transparent',
}}
>
{!!image && <img src={image} alt={name} />}
</div>
);
}

View File

@ -0,0 +1,23 @@
import type {ProductVariantFragment} from 'storefrontapi.generated';
import {Image} from '@shopify/hydrogen';
export function ProductImage({
image,
}: {
image: ProductVariantFragment['image'];
}) {
if (!image) {
return <div className="product-image" />;
}
return (
<div className="product-image">
<Image
alt={image.altText || 'Product Image'}
aspectRatio="1/1"
data={image}
key={image.id}
sizes="(min-width: 45em) 50vw, 100vw"
/>
</div>
);
}

View File

@ -0,0 +1,57 @@
import {Link} from 'react-router';
import {Image, Money} from '@shopify/hydrogen';
import type {
ProductItemFragment,
CollectionItemFragment,
} from 'storefrontapi.generated';
import {useVariantUrl} from '~/lib/variants';
function isValidMoney(
value: unknown,
): value is {amount: string; currencyCode: string} {
if (value == null || typeof value !== 'object') return false;
const v = value as {amount?: unknown; currencyCode?: unknown};
return (
typeof v.amount === 'string' &&
v.amount.length > 0 &&
typeof v.currencyCode === 'string' &&
v.currencyCode.length > 0
);
}
export function ProductItem({
product,
loading,
}: {
product: CollectionItemFragment | ProductItemFragment;
loading?: 'eager' | 'lazy';
}) {
const variantUrl = useVariantUrl(product.handle);
const image = product.featuredImage;
return (
<Link
className="product-item"
key={product.id}
prefetch="intent"
to={variantUrl}
>
{image && (
<Image
alt={image.altText || product.title}
aspectRatio="1/1"
data={image}
loading={loading}
sizes="(min-width: 45em) 400px, 100vw"
/>
)}
<h4>{product.title}</h4>
<small>
{isValidMoney(product.priceRange?.minVariantPrice) ? (
<Money data={product.priceRange.minVariantPrice} />
) : (
'—'
)}
</small>
</Link>
);
}

View File

@ -0,0 +1,27 @@
import {Money} from '@shopify/hydrogen';
import type {MoneyV2} from '@shopify/hydrogen/storefront-api-types';
export function ProductPrice({
price,
compareAtPrice,
}: {
price?: MoneyV2;
compareAtPrice?: MoneyV2 | null;
}) {
return (
<div aria-label="Price" className="product-price" role="group">
{compareAtPrice ? (
<div className="product-price-on-sale">
{price ? <Money data={price} /> : null}
<s>
<Money data={compareAtPrice} />
</s>
</div>
) : price ? (
<Money data={price} />
) : (
<span>&nbsp;</span>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More