Fix nested git repository and track all inner files
|
|
@ -1 +0,0 @@
|
|||
Subproject commit a775bf37795d7eb10aab04e655ace174afa23749
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
node_modules
|
||||
/.cache
|
||||
/build
|
||||
/dist
|
||||
/public/build
|
||||
/.mf
|
||||
.env
|
||||
.shopify
|
||||
.react-router
|
||||
scripts/pdp-seed.json
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
legacy-peer-deps=true
|
||||
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 690 KiB |
|
|
@ -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 |
|
After Width: | Height: | Size: 790 KiB |
|
After Width: | Height: | Size: 723 KiB |
|
After Width: | Height: | Size: 864 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -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 |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 671 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 988 KiB |
|
After Width: | Height: | Size: 751 KiB |
|
After Width: | Height: | Size: 724 KiB |
|
After Width: | Height: | Size: 569 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 506 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 601 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 758 KiB |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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’t placed an order yet.{' '}
|
||||
<Link className="text-[#5d0b24] no-underline hover:opacity-80" to="/collections/all">
|
||||
Browse the shop
|
||||
</Link>
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
×
|
||||
</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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} </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>−</span>
|
||||
</button>
|
||||
</CartLineUpdateButton>
|
||||
{layout === 'aside' ?
|
||||
<span className="cart-line-qty-abiir__value">{quantity}</span>
|
||||
: (
|
||||
<>
|
||||
|
||||
</>
|
||||
)}
|
||||
<CartLineUpdateButton lines={[{id: lineId, quantity: nextQuantity}]}>
|
||||
<button
|
||||
aria-label="Increase quantity"
|
||||
name="increase-quantity"
|
||||
value={nextQuantity}
|
||||
disabled={!!isOptimistic}
|
||||
type="submit"
|
||||
>
|
||||
<span>+</span>
|
||||
</button>
|
||||
</CartLineUpdateButton>
|
||||
{layout !== 'aside' ?
|
||||
<>
|
||||
|
||||
<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('-');
|
||||
}
|
||||
|
|
@ -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’t added anything yet — let’s get you started.
|
||||
</p>
|
||||
<Link
|
||||
to="/collections/all"
|
||||
onClick={close}
|
||||
prefetch="viewport"
|
||||
className="cart-empty-aside__link"
|
||||
>
|
||||
Continue shopping
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 →</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>
|
||||
|
||||
<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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
<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}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={`Remove gift card ending in ${lastCharacters}`}
|
||||
onClick={onRemoveClick}
|
||||
ref={buttonRef}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</CartForm>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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’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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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> </span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||