Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit d689cf8

Browse files
authoredFeb 18, 2022
Add initial components and dependencies for the GA4 ecommerce demo (#824)
* Add the global context object of the GA4 ecommerce demo * Wrap every page using a StoreProvider object used by eCommerce demo. * Add CSS variables used by eCommerce demo. * Add dependencies for eCommerce demo app. * Add "Go to Cart" button component. * Add "Navigation bar" component. * Add "header" component. * Add "footer" component. * Add "Google Analytics Console" component which displays all ecommerce events generated by the demo app. * Do not lint CSS * Add support for CSS modules * Address type check errors by introducing interfaces. * Address type check errors by introducing interfaces. * add EOF
1 parent 56e2f31 commit d689cf8

16 files changed

+733
-0
lines changed
 

‎.eslintignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
src/images/*
22
**/*.json
3+
**/*.css
34
**/*.lock
45
lib/build/*
56
node_modules/

‎gatsby-browser.js

+8
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
import * as React from "react"
2+
import {StoreProvider} from "./src/components/ga4/EnhancedEcommerce/store-context"
13
import CustomLayout from "./gatsby/wrapRootElement.js"
4+
import "./src/styles/ecommerce/variables.css"
25

36
// TODO - look into making this work like gatsby-node & use typescript for the
47
// things that are imported/exported.
58

69
export { onInitialClientRender } from "./gatsby/onInitialClientRender"
710
export const wrapPageElement = CustomLayout
11+
12+
// Wrap every page using a StoreProvider object used by eCommerce demo.
13+
export const wrapRootElement = ({ element }) => (
14+
<StoreProvider>{element}</StoreProvider>
15+
)

‎package.json

+3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"gatsby-plugin-typescript": "^3.4.0",
3030
"gatsby-plugin-use-query-params": "^1.0.1",
3131
"gatsby-source-filesystem": "^3.4.0",
32+
"gatsby-transformer-json": "^3.12.0",
3233
"gatsby-transformer-sharp": "^3.4.0",
3334
"immutable": "^4.0.0-rc.12",
3435
"js-base64": "^3.6.1",
@@ -38,9 +39,11 @@
3839
"react-dom": "^17.0.2",
3940
"react-error-boundary": "^3.1.3",
4041
"react-helmet": "^6.1.0",
42+
"react-icons": "^4.2.0",
4143
"react-json-view": "^1.21.3",
4244
"react-loader-spinner": "^4.0.0",
4345
"react-redux": "^7.2.4",
46+
"react-router-dom": "^6.0.2",
4447
"react-syntax-highlighter": "^15.4.3",
4548
"redux": "^4.0.5",
4649
"use-debounce": "^6.0.0",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
.cartButton {
2+
color: var(--text-color-secondary);
3+
grid-area: cartButton;
4+
width: var(--size-input);
5+
height: var(--size-input);
6+
display: flex;
7+
justify-content: center;
8+
align-items: center;
9+
position: relative;
10+
align-self: center;
11+
}
12+
13+
.cartButton:hover {
14+
color: var(--text-color);
15+
}
16+
17+
.badge {
18+
display: flex;
19+
align-items: center;
20+
justify-content: center;
21+
background-color: var(--primary);
22+
box-shadow: 0 0 0 2px white;
23+
color: var(--text-color-inverted);
24+
font-size: var(--text-xs);
25+
font-weight: var(--bold);
26+
border-radius: var(--radius-rounded);
27+
position: absolute;
28+
bottom: 4px;
29+
right: 4px;
30+
height: 16px;
31+
min-width: 16px;
32+
padding: 0 var(--space-sm);
33+
}
34+
35+
.cartButton[aria-current="page"] {
36+
color: var(--primary);
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as React from "react"
2+
import {Link} from "gatsby"
3+
import {badge, cartButton} from "./cart-button.module.css"
4+
import {MdShoppingCart} from 'react-icons/md';
5+
import IconButton from "@material-ui/core/IconButton"
6+
7+
export function CartButton({quantity}) {
8+
return (
9+
<Link
10+
aria-label={`Shopping Cart with ${quantity} items`}
11+
to="/ga4/enhanced-ecommerce/cart"
12+
className={cartButton}
13+
>
14+
<IconButton>
15+
<MdShoppingCart/>
16+
</IconButton>
17+
{quantity > 0 && <div className={badge}>{quantity}</div>}
18+
</Link>
19+
)
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.footerStyle {
2+
margin-top: 100px;
3+
}
4+
5+
.gaConsole {
6+
padding: var(--size-gutter-raw);
7+
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as React from "react"
2+
import {footerStyle, gaConsole} from "./footer.module.css"
3+
import {GaConsole} from "@/components/ga4/EnhancedEcommerce/ga-console";
4+
5+
export function Footer() {
6+
return (
7+
<footer className={footerStyle}>
8+
<GaConsole className={gaConsole}/>
9+
</footer>
10+
)
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
.gaConsoleStyle {
2+
align-self: stretch;
3+
height: 100px;
4+
align-items: center;
5+
background: black;
6+
color: white;
7+
overflow: auto;
8+
padding: var(--size-gap) var(--size-gutter);
9+
position: fixed;
10+
bottom: 0;
11+
width: 80%;
12+
opacity: 0.7;
13+
}
14+
15+
.emptyEvents {
16+
text-align: center;
17+
font-weight: var(--medium);
18+
}
19+
20+
.eventLine {
21+
display: flex;
22+
flex-direction: row;
23+
white-space: nowrap;
24+
}
25+
26+
.eventTimestamp {
27+
padding-right: var(--space-md);
28+
}
29+
30+
.eventName {
31+
padding-right: var(--space-md);
32+
font-weight: var(--semibold);
33+
}
34+
35+
.eventDescription {
36+
padding-right: var(--space-md);
37+
}
38+
39+
.eventSnippet {
40+
white-space: nowrap;
41+
overflow: hidden;
42+
text-overflow: ellipsis;
43+
font-style: italic;
44+
color: var(--grey-50);
45+
text-decoration: dotted underline;
46+
cursor: pointer;
47+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import * as React from "react"
2+
import {StoreContext} from "./store-context"
3+
import {emptyEvents, eventDescription, eventLine, eventName, eventSnippet, eventTimestamp, gaConsoleStyle} from "@/components/ga4/EnhancedEcommerce/ga-console.module.css";
4+
import Dialog from '@material-ui/core/Dialog';
5+
import Tabs from '@material-ui/core/Tabs';
6+
import Tab from '@material-ui/core/Tab';
7+
import DialogActions from '@material-ui/core/DialogActions';
8+
import DialogContent from '@material-ui/core/DialogContent';
9+
import DialogContentText from '@material-ui/core/DialogContentText';
10+
import DialogTitle from '@material-ui/core/DialogTitle';
11+
import Button from '@material-ui/core/Button';
12+
import TextField from '@material-ui/core/TextField';
13+
import {Link} from "gatsby";
14+
import {Box, Typography} from "@material-ui/core";
15+
16+
function TabPanel(props) {
17+
const {children, value, index, ...other} = props;
18+
19+
return (
20+
<div
21+
role="tabpanel"
22+
hidden={value !== index}
23+
id={`simple-tabpanel-${index}`}
24+
{...other}
25+
>
26+
{value === index && (
27+
<Box p={3}>
28+
<Typography>{children}</Typography>
29+
</Box>
30+
)}
31+
</div>
32+
);
33+
}
34+
35+
export function GaConsole({className}) {
36+
const {events} = React.useContext(StoreContext)
37+
const [open, setOpen] = React.useState(false);
38+
39+
const [selectedEvent, setSelectedEvent] = React.useState({
40+
key: 0,
41+
timestamp: '',
42+
name: '',
43+
description: '',
44+
snippet: ''
45+
});
46+
47+
const [value, setValue] = React.useState(0);
48+
49+
const handleClickOpen = (eventKey) => () => {
50+
setSelectedEvent(events[events.length - eventKey - 1])
51+
setOpen(true);
52+
};
53+
const handleClose = () => {
54+
setOpen(false);
55+
};
56+
const handleChange = (event, newValue) => {
57+
setValue(newValue);
58+
};
59+
60+
return (
61+
<div className={[gaConsoleStyle, className].join(" ")}>
62+
{events.length ? events.map((event) => (
63+
<div key={event.key} className={eventLine}>
64+
<div className={eventTimestamp}>{event.timestamp}</div>
65+
<div className={eventName}>
66+
<Link
67+
to={`https://developers.google.com/gtagjs/reference/ga4-events#${event.name}`}
68+
aria-label={`GA4 event reference documentation`}
69+
target='_blank'
70+
>
71+
{event.name}
72+
</Link></div>
73+
<div
74+
className={eventDescription}>{event.description}</div>
75+
<div onClick={handleClickOpen(event.key)}
76+
className={eventSnippet}>{event.snippet}</div>
77+
</div>
78+
)) : <div className={emptyEvents}>Start interacting with the store
79+
to see Google Analytics eCommerce events here.</div>}
80+
81+
<Dialog
82+
open={open}
83+
onClose={handleClose}
84+
aria-labelledby="scroll-dialog-title"
85+
aria-describedby="scroll-dialog-description"
86+
>
87+
<DialogTitle id="scroll-dialog-title">Google Analytics eCommerce
88+
event details</DialogTitle>
89+
<DialogContent>
90+
<DialogContentText
91+
tabIndex={-1}
92+
>
93+
<p>{selectedEvent?.timestamp}&nbsp;
94+
<Link
95+
to={`https://developers.google.com/gtagjs/reference/ga4-events#${selectedEvent?.name}`}
96+
aria-label={`GA4 event reference documentation`}
97+
target='_blank'
98+
>
99+
{selectedEvent?.name}
100+
</Link> {selectedEvent?.description}</p>
101+
<Tabs value={value} onChange={handleChange}>
102+
<Tab label="gtag.js Code"/>
103+
<Tab label="Google Tag Manager Code" disabled/>
104+
105+
</Tabs>
106+
<TabPanel index={0}>
107+
Item One
108+
</TabPanel>
109+
<TextField
110+
multiline
111+
defaultValue={selectedEvent?.snippet}
112+
variant="filled"
113+
fullWidth={true}
114+
InputProps={{
115+
readOnly: true,
116+
}}
117+
/>
118+
</DialogContentText>
119+
</DialogContent>
120+
<DialogActions>
121+
<Button color="primary">
122+
Copy
123+
</Button>
124+
<Button onClick={handleClose} color="primary">
125+
Close
126+
</Button>
127+
</DialogActions>
128+
</Dialog>
129+
</div>
130+
)
131+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
.container {
2+
display: flex;
3+
flex-direction: column;
4+
align-items: center;
5+
}
6+
7+
.header {
8+
display: grid;
9+
width: 100%;
10+
padding: var(--size-gap) var(--size-gutter);
11+
grid-template-columns: 1fr;
12+
grid-template-areas: "cartButton" "navHeader";
13+
align-items: start;
14+
background-color: var(--background);
15+
}
16+
17+
@media (min-width: 640px) {
18+
.header {
19+
grid-template-columns: 1fr min-content;
20+
grid-template-areas: "navHeader cartButton";
21+
}
22+
}
23+
24+
25+
.nav {
26+
grid-area: navHeader;
27+
align-self: stretch;
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import * as React from "react"
2+
import {StoreContext} from "./store-context"
3+
import {CartButton} from "./cart-button"
4+
import {Navigation} from "./navigation"
5+
6+
import {container, header, nav,} from "./header.module.css"
7+
8+
export function Header() {
9+
const {cart} = React.useContext(StoreContext)
10+
11+
const items = cart ? cart : []
12+
13+
const quantity = items.reduce((total, item) => {
14+
return total + item.quantity
15+
}, 0)
16+
17+
return (
18+
19+
<div className={container}>
20+
<header className={header}>
21+
<Navigation className={nav}/>
22+
<CartButton quantity={quantity}/>
23+
</header>
24+
</div>
25+
)
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
.navStyle {
2+
display: flex;
3+
flex-direction: row;
4+
align-items: center;
5+
overflow-x: auto;
6+
white-space: nowrap;
7+
font-weight: var(--medium);
8+
}
9+
10+
.navLink {
11+
cursor: pointer;
12+
text-decoration: none;
13+
height: var(--size-input);
14+
display: flex;
15+
color: var(--text-color-secondary);
16+
align-items: center;
17+
padding-left: var(--space-md);
18+
padding-right: var(--space-md);
19+
}
20+
21+
.navLink:hover {
22+
color: var(--text-color);
23+
}
24+
25+
.activeLink,
26+
.navLink[aria-active="page"] {
27+
color: var(--primary);
28+
text-decoration: underline;
29+
text-decoration-thickness: 2px;
30+
text-underline-offset: 4px;
31+
}
32+
33+
.activeLink:hover,
34+
.navLink[aria-active="page"]:hover {
35+
color: var(--primary);
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {Link} from "gatsby"
2+
import * as React from "react"
3+
import {navStyle, navLink, activeLink} from "./navigation.module.css"
4+
5+
export function Navigation({className}) {
6+
return (
7+
<nav className={[navStyle, className].join(" ")}>
8+
<Link
9+
key="All"
10+
className={navLink}
11+
to="/ga4/enhanced-ecommerce"
12+
activeClassName={activeLink}
13+
>
14+
All products
15+
</Link>
16+
</nav>
17+
)
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import * as React from "react"
2+
3+
interface GAEvent
4+
{
5+
key: number
6+
timestamp: string
7+
name: string
8+
description: string
9+
snippet: string
10+
}
11+
12+
interface Product
13+
{
14+
id: number
15+
title: string
16+
brand: string
17+
category: string
18+
price: number
19+
}
20+
21+
interface CartItem
22+
{
23+
id: number
24+
product: Product
25+
variantId: string
26+
quantity: number
27+
}
28+
29+
interface PostalAddress
30+
{
31+
firstName: string
32+
lastName: string
33+
addressLine1: string
34+
addressLine2: string
35+
city: string
36+
provinceState: string
37+
zipPostalCode: string
38+
country: string
39+
}
40+
41+
interface CheckoutState
42+
{
43+
email: string
44+
shippingAddress: PostalAddress
45+
billingAddress: PostalAddress
46+
paymentMethod: string
47+
shippingMethod: string
48+
coupon: string
49+
}
50+
51+
interface StoreContextValues {
52+
events: GAEvent[]
53+
cart: CartItem[]
54+
lastCart: CartItem[]
55+
isOpen: boolean
56+
checkoutState: CheckoutState
57+
onOpen(): void
58+
onClose(): void
59+
addEvent(name: string, description: string, snippet: string): void
60+
addVariantToCart(product: Product, variantId: string,
61+
quantity: number): void
62+
removeLineItem(id: number): void
63+
updateLineItem(id: number, quantity: number): void
64+
getCartSubtotal(): number
65+
updateCheckoutState(name: string, value: string|number): void
66+
updateShippingAddress(name: string, value: string|number): void
67+
updateBillingAddress(name: string, value: string|number): void
68+
emptyCart(): CartItem[]
69+
}
70+
71+
const defaultValues: StoreContextValues = {
72+
cart: [],
73+
lastCart: [],
74+
events: [],
75+
isOpen: false,
76+
checkoutState: {
77+
email: '',
78+
shippingAddress: {
79+
firstName: '',
80+
lastName: '',
81+
addressLine1: '',
82+
addressLine2: '',
83+
city: '',
84+
provinceState: '',
85+
country: '',
86+
zipPostalCode: ''
87+
},
88+
shippingMethod: '',
89+
billingAddress: {
90+
firstName: '',
91+
lastName: '',
92+
addressLine1: '',
93+
addressLine2: '',
94+
city: '',
95+
provinceState: '',
96+
country: '',
97+
zipPostalCode: ''
98+
},
99+
coupon: '',
100+
paymentMethod: ''
101+
},
102+
onOpen: () => {
103+
},
104+
onClose: () => {
105+
},
106+
addEvent: () => {
107+
},
108+
addVariantToCart: () => {
109+
},
110+
removeLineItem: () => {
111+
},
112+
updateLineItem: () => {
113+
},
114+
getCartSubtotal: () => {
115+
return 0
116+
},
117+
updateCheckoutState: () => {
118+
},
119+
updateShippingAddress: () => {
120+
},
121+
updateBillingAddress: () => {
122+
},
123+
emptyCart: () => {
124+
return []
125+
}
126+
}
127+
128+
129+
export const StoreContext = React.createContext(defaultValues)
130+
131+
export const StoreProvider = ({children}) => {
132+
const [cart, setCart] = React.useState(defaultValues.cart)
133+
const [lastCart, setLastCart] = React.useState(defaultValues.lastCart)
134+
135+
const [checkoutState, setCheckoutState] = React.useState(defaultValues.checkoutState)
136+
const [events, setEvents] = React.useState(defaultValues.events)
137+
138+
const addEvent = (name, description, snippet) => {
139+
const key = events.length
140+
const timestamp = new Intl.DateTimeFormat('default', {
141+
hour: 'numeric',
142+
minute: 'numeric',
143+
second: 'numeric',
144+
hour12: false,
145+
}).format(Date.now())
146+
147+
const newEvents = [{
148+
key,
149+
timestamp,
150+
name,
151+
description,
152+
snippet
153+
}].concat(events);
154+
setEvents(newEvents);
155+
}
156+
const addVariantToCart = (product, variantId, quantity) => {
157+
const id = cart.length;
158+
const cartItems = [
159+
{
160+
id,
161+
product,
162+
variantId,
163+
quantity: parseInt(quantity, 10),
164+
},
165+
]
166+
const newCart = cart.concat(cartItems)
167+
setCart(newCart)
168+
}
169+
170+
const removeLineItem = (id) => {
171+
const newCart = cart.filter(item => item.id !== id);
172+
setCart(newCart)
173+
}
174+
175+
const updateLineItem = (id, quantity) => {
176+
const newCart = [ ...cart]
177+
const item = newCart.find(item => item.id === id);
178+
if( item )
179+
{
180+
item.quantity = parseInt(quantity, 10);
181+
}
182+
setCart(newCart);
183+
}
184+
185+
const getCartSubtotal = () => {
186+
return cart.reduce((sum,x) => sum + Number(x.product.price) * x.quantity, 0);
187+
}
188+
189+
const updateCheckoutState = (name, value) =>
190+
{
191+
const newCheckoutState = { ...checkoutState };
192+
newCheckoutState[name] = value;
193+
setCheckoutState(newCheckoutState);
194+
}
195+
196+
const updateShippingAddress = (name, value) =>
197+
{
198+
const newCheckoutState = { ...checkoutState };
199+
newCheckoutState.shippingAddress[name] = value;
200+
setCheckoutState(newCheckoutState);
201+
}
202+
203+
const updateBillingAddress = (name, value) =>
204+
{
205+
const newCheckoutState = { ...checkoutState };
206+
newCheckoutState.billingAddress[name] = value;
207+
setCheckoutState(newCheckoutState);
208+
}
209+
210+
const emptyCart = () =>
211+
{
212+
const cartSnapshot = [ ...cart ];
213+
setCart([]);
214+
setLastCart(cart)
215+
return cartSnapshot;
216+
}
217+
218+
return (
219+
<StoreContext.Provider
220+
value={{
221+
...defaultValues,
222+
addEvent,
223+
addVariantToCart,
224+
removeLineItem,
225+
updateLineItem,
226+
getCartSubtotal,
227+
updateCheckoutState,
228+
updateShippingAddress,
229+
updateBillingAddress,
230+
emptyCart,
231+
cart,
232+
checkoutState,
233+
lastCart,
234+
events
235+
}}
236+
>
237+
{children}
238+
</StoreContext.Provider>
239+
)
240+
}

‎src/global.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ declare module "*.svg" {
2020
export default value
2121
}
2222

23+
declare module "*.module.css";
24+
2325
declare interface AppState {
2426
user?: gapi.auth2.GoogleUser
2527
gapi?: typeof gapi

‎src/styles/ecommerce/variables.css

+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
:root {
2+
/* tokens */
3+
/* font-family */
4+
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
5+
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
6+
7+
/* palette */
8+
--black-fade-5: rgba(0, 0, 0, 0.05);
9+
--black-fade-40: rgba(0, 0, 0, 0.4);
10+
--grey-90: #232129;
11+
--grey-50: #78757a;
12+
--green-80: #088413;
13+
--green-50-rgb: 55, 182, 53;
14+
--white: #ffffff;
15+
16+
/* radii */
17+
--radius-sm: 4px;
18+
--radius-md: 8px;
19+
--radius-rounded: 999px;
20+
21+
/* spacing */
22+
--space-sm: 4px;
23+
--space-md: 8px;
24+
--space-lg: 16px;
25+
--space-xl: 20px;
26+
--space-2xl: 24px;
27+
--space-3xl: 48px;
28+
29+
/* line-height */
30+
--solid: 1;
31+
--dense: 1.25;
32+
--default: 1.5;
33+
--loose: 2;
34+
35+
/* letter-spacing */
36+
--tracked: 0.075em;
37+
--tight: -0.015em;
38+
39+
/* font-weight */
40+
--body: 400;
41+
--medium: 500;
42+
--semibold: 600;
43+
--bold: 700;
44+
45+
/* font-size */
46+
--text-xs: 12px;
47+
--text-sm: 14px;
48+
--text-md: 16px;
49+
--text-lg: 18px;
50+
--text-xl: 20px;
51+
--text-2xl: 24px;
52+
--text-3xl: 32px;
53+
54+
/* role-based tokens */
55+
56+
/* colors */
57+
--primary: var(--green-80);
58+
--background: var(--white);
59+
--border: var(--black-fade-5);
60+
61+
/* transitions */
62+
--transition: box-shadow 0.125s ease-in;
63+
64+
/* shadows */
65+
--shadow: 0 4px 12px rgba(var(--green-50-rgb), 0.5);
66+
67+
/* text */
68+
/* color */
69+
--text-color: var(--grey-90);
70+
--text-color-secondary: var(--grey-50);
71+
--text-color-inverted: var(--white);
72+
/* size */
73+
--text-display: var(--text-2xl);
74+
--text-prose: var(--text-md);
75+
76+
/* input */
77+
--input-background: var(--black-fade-5);
78+
--input-background-hover: var(--black-fade-5);
79+
--input-border: var(--black-fade-5);
80+
--input-text: var(--text-color);
81+
--input-text-disabled: var(--black-fade-40);
82+
--input-ui: var(--text-color-secondary);
83+
--input-ui-active: var(--text-color);
84+
85+
/* size */
86+
--size-input: var(--space-3xl);
87+
--size-gap: 12px;
88+
--size-gutter-raw: var(--space-2xl);
89+
--size-gutter: calc(var(--size-gutter-raw) - 12px);
90+
91+
/* product */
92+
--product-grid: 1fr;
93+
}
94+
95+
/* role-based token adjustments per breakpoint */
96+
@media (min-width: 640px) {
97+
:root {
98+
--product-grid: 1fr 1fr;
99+
}
100+
}
101+
102+
@media (min-width: 1024px) {
103+
:root {
104+
--text-display: var(--text-3xl);
105+
--text-prose: var(--text-lg);
106+
--product-grid: repeat(3, 1fr);
107+
--size-gutter-raw: var(--space-3xl);
108+
--size-gap: var(--space-2xl);
109+
}
110+
}
111+
112+
@media (min-width: 1280px) {
113+
:root {
114+
--product-grid: repeat(4, 1fr);
115+
}
116+
}
117+

0 commit comments

Comments
 (0)
Please sign in to comment.