replace Tailwind utilities in product + cart components with CSS (Phase 5b)

Remove ~140 Tailwind utility classes from product.ex and cart.ex, replacing
with semantic CSS classes in components.css. Delete helper functions that
generated Tailwind class strings (card_classes, image_container_classes,
content_padding_class, title_classes, hero_cta_classes, grid_classes).
Use data-* attributes for variant styling, grid columns, and sticky
positioning. Update theme-layer2 selectors for renamed classes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-17 10:32:48 +00:00
parent fc9c33ab0c
commit 04b6ee3f37
5 changed files with 775 additions and 239 deletions

View File

@ -18,6 +18,8 @@
.product-card {
background-color: var(--t-surface-raised);
border-radius: var(--t-radius-card);
overflow: hidden;
transition: all 0.2s ease;
&[data-variant="default"],
&[data-variant="compact"] {
@ -33,6 +35,10 @@
cursor: pointer;
}
&[data-variant="featured"]:hover {
transform: translateY(-0.25rem);
}
&[data-clickable] {
position: relative;
}
@ -45,10 +51,32 @@
.product-card-image-wrap {
z-index: 1;
overflow: hidden;
position: relative;
background-color: #e5e7eb;
}
.product-card-image-wrap img {
width: 100%;
height: 100%;
object-fit: cover;
}
.product-image-primary {
transition: opacity 0.3s ease;
}
.product-card-placeholder {
color: var(--t-text-tertiary);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-icon {
width: 3rem;
height: 3rem;
opacity: 0.4;
}
.product-card-category {
@ -56,10 +84,19 @@
text-decoration: none;
position: relative;
z-index: 1;
font-size: var(--t-text-caption);
margin-bottom: 0.25rem;
display: block;
}
.product-card-category:where(a):hover {
text-decoration: underline;
}
.product-card-title {
color: var(--t-text-primary);
font-weight: 600;
margin-bottom: 0.5rem;
}
.product-card[data-variant="default"] .product-card-title,
@ -67,8 +104,28 @@
font-family: var(--t-font-heading);
}
.product-card[data-variant="featured"] .product-card-title {
font-size: var(--t-text-small);
font-weight: 500;
margin-bottom: 0.25rem;
}
.product-card[data-variant="compact"] .product-card-title {
font-size: var(--t-text-small);
margin-bottom: 0.25rem;
}
.product-card[data-variant="minimal"] .product-card-title {
font-size: var(--t-text-caption);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-card-delivery {
color: var(--t-text-tertiary);
font-size: var(--t-text-caption);
margin-top: 0.25rem;
}
/* ── Product prices (shared between cards and PDP) ── */
@ -79,6 +136,7 @@
.product-price--compare {
color: var(--t-text-tertiary);
text-decoration: line-through;
}
.product-price--regular {
@ -89,14 +147,66 @@
color: var(--t-text-secondary);
}
/* Card-variant price sizing */
.product-card[data-variant="default"] .product-price--sale,
.product-card[data-variant="default"] .product-price--regular {
font-size: var(--t-text-large);
font-weight: 700;
}
.product-card[data-variant="default"] .product-price--compare {
font-size: var(--t-text-small);
margin-left: 0.5rem;
}
.product-card[data-variant="featured"] .product-price--secondary {
font-size: var(--t-text-small);
}
.product-card[data-variant="featured"] .product-price--compare {
margin-right: 0.25rem;
}
.product-card[data-variant="compact"] .product-price--regular {
font-weight: 700;
}
.product-card[data-variant="minimal"] .product-price--secondary {
font-size: var(--t-text-caption);
}
/* PDP price sizing */
.pdp-price-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.pdp-price-row .product-price--sale,
.pdp-price-row .product-price--regular {
font-size: var(--t-heading-lg);
font-weight: 700;
}
.pdp-price-row .product-price--compare {
font-size: var(--t-text-xl);
}
.sale-badge {
background-color: var(--t-sale-color);
padding: 0.25rem 0.5rem;
font-size: var(--t-text-small);
font-weight: 700;
color: #fff;
border-radius: var(--t-radius-sm, 4px);
}
/* ── Hero section ── */
.hero-section {
padding: var(--space-2xl) var(--space-lg);
text-align: center;
&[data-background="base"] {
background-color: var(--t-surface-base);
@ -105,21 +215,73 @@
&[data-background="sunken"] {
background-color: var(--t-surface-sunken);
}
& .t-heading {
font-size: var(--t-heading-lg);
margin-bottom: 1rem;
}
}
.hero-section--page {
padding-top: var(--space-2xl);
text-align: center;
& .t-heading {
font-size: var(--t-heading-xl);
margin-bottom: 1.5rem;
}
& .hero-description {
margin-bottom: 3rem;
max-width: 42rem;
}
}
.hero-error {
text-align: center;
& .t-heading {
font-size: var(--t-heading-lg);
margin-bottom: 1.5rem;
}
& .hero-description {
margin-bottom: 2rem;
max-width: 28rem;
}
}
.hero-pre-title {
font-family: var(--t-font-heading);
font-weight: var(--t-heading-weight);
color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));
font-size: var(--t-heading-display);
margin-bottom: 1rem;
}
.hero-description {
color: var(--t-text-secondary);
line-height: 1.6;
font-size: var(--t-text-large);
max-width: 32rem;
margin-inline: auto;
margin-bottom: 2rem;
}
.hero-cta-group {
display: flex;
flex-direction: column;
gap: 1rem;
justify-content: center;
}
.hero-cta {
padding: 0.75rem 2rem;
font-weight: 600;
transition: all 0.2s ease;
text-decoration: none;
display: inline-block;
cursor: pointer;
}
/* ── Category nav ── */
@ -129,14 +291,49 @@
background-color: var(--t-surface-base);
}
.category-nav {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
max-width: 48rem;
margin-inline: auto;
}
.category-card {
text-decoration: none;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
transition: background-color 0.15s ease;
}
.category-card:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.category-image {
width: 6rem;
height: 6rem;
border-radius: 9999px;
background-color: #e5e7eb;
background-size: cover;
background-position: center;
transition: transform 0.15s ease;
}
.category-card:hover .category-image {
transform: scale(1.05);
}
.category-name {
font-family: var(--t-font-body);
color: var(--t-text-primary);
font-size: var(--t-text-small);
font-weight: 500;
}
/* ── Featured products section ── */
@ -144,6 +341,16 @@
.featured-section {
padding: var(--space-xl) var(--space-lg);
background-color: var(--t-surface-sunken);
& .t-heading {
font-size: var(--t-text-2xl);
margin-bottom: 1.5rem;
}
}
.featured-cta-wrap {
text-align: center;
margin-top: 2rem;
}
.outline-button {
@ -153,6 +360,10 @@
border-radius: var(--t-radius-button);
cursor: pointer;
text-decoration: none;
padding: 0.75rem 1.5rem;
font-weight: 500;
transition: all 0.2s ease;
display: inline-block;
}
/* ── Image + text section ── */
@ -160,21 +371,38 @@
.image-text-section {
padding: var(--space-2xl) var(--space-lg);
background-color: var(--t-surface-base);
display: grid;
grid-template-columns: 1fr;
gap: 3rem;
align-items: center;
& .t-heading {
font-size: var(--t-text-2xl);
margin-bottom: 1rem;
}
}
.image-text-image {
border-radius: var(--t-radius-image);
height: 18rem;
background-size: cover;
background-position: center;
}
.image-text-body {
color: var(--t-text-secondary);
line-height: 1.7;
font-size: var(--t-text-base);
margin-bottom: 1rem;
}
.accent-link {
color: var(--t-accent-text, hsl(var(--t-accent-h) var(--t-accent-s) 38%));
text-decoration: none;
cursor: pointer;
font-size: var(--t-text-small);
font-weight: 500;
transition: color 0.15s ease;
}
/* ── Collection header ── */
@ -182,12 +410,41 @@
.collection-header-wrap {
background-color: var(--t-surface-raised);
border-color: var(--t-border-default);
border-bottom: 1px solid var(--t-border-default);
& .t-heading {
font-size: var(--t-heading-lg);
margin-bottom: 0.5rem;
}
}
.collection-header-inner {
max-width: 80rem;
margin-inline: auto;
padding: 2rem 1rem;
}
.collection-header-meta {
color: var(--t-text-secondary);
}
/* ── Filter bar ── */
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.filter-pills-container {
display: flex;
gap: 0.5rem;
overflow-x: auto;
}
/* ── Breadcrumb ── */
.breadcrumb {
@ -196,12 +453,22 @@
& [aria-current="page"] {
color: var(--t-text-primary);
}
& a:hover {
text-decoration: underline;
}
}
/* ── Related products ── */
.related-section {
border-top: 1px solid var(--t-border-default);
padding-block: 3rem;
& .t-heading {
font-size: var(--t-text-2xl);
margin-bottom: 1.5rem;
}
}
/* ── PDP gallery ── */
@ -209,16 +476,42 @@
.pdp-gallery-frame {
border-radius: var(--t-radius-image);
overflow: hidden;
position: relative;
}
.pdp-gallery-single img {
width: 100%;
height: 100%;
object-fit: cover;
}
.pdp-thumbnail {
border-radius: var(--t-radius-image);
aspect-ratio: 1 / 1;
background-color: #e5e7eb;
overflow: hidden;
border: none;
padding: 0;
cursor: pointer;
}
.pdp-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ── Variant selector ── */
.variant-selector {
margin-bottom: 1.5rem;
}
.variant-label {
color: var(--t-text-primary);
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
}
.variant-label-value {
@ -226,8 +519,21 @@
font-weight: normal;
}
.variant-options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.color-swatch {
border-color: var(--t-border-default);
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
border: 2px solid var(--t-border-default);
transition: all 0.2s ease;
position: relative;
cursor: pointer;
padding: 0;
&[aria-pressed="true"] {
border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));
@ -240,6 +546,10 @@
border-radius: var(--t-radius-button);
color: var(--t-text-primary);
background: transparent;
padding: 0.5rem 1rem;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
&[aria-pressed="true"] {
border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));
@ -249,30 +559,60 @@
/* ── Quantity selector ── */
.quantity-selector {
margin-bottom: 2rem;
}
.qty-label {
color: var(--t-text-primary);
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
}
.qty-row {
display: flex;
align-items: center;
gap: 1rem;
}
.qty-group {
border: 2px solid var(--t-border-default);
border-radius: var(--t-radius-input);
display: flex;
align-items: center;
}
.qty-btn {
color: var(--t-text-primary);
padding: 0.5rem 1rem;
background: none;
border: none;
cursor: pointer;
&:disabled {
opacity: 0.3;
}
}
.qty-display {
border-color: var(--t-border-default);
color: var(--t-text-primary);
padding: 0.5rem 1rem;
border-inline: 2px solid var(--t-border-default);
min-width: 3rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
.stock-in {
color: var(--t-text-tertiary);
font-size: var(--t-text-small);
}
.stock-out {
color: var(--t-sale-color);
font-size: var(--t-text-small);
font-weight: 600;
}
/* ── Add to cart ── */
@ -280,6 +620,17 @@
.atc-wrap {
background-color: var(--t-surface-base);
border-color: var(--t-border-subtle);
margin-bottom: 1rem;
&[data-sticky="true"] {
position: sticky;
bottom: 0;
z-index: 10;
padding-block: 0.75rem;
margin-inline: -1rem;
padding-inline: 1rem;
border-top: 1px solid var(--t-border-subtle);
}
}
.atc-btn {
@ -288,6 +639,11 @@
border-radius: var(--t-radius-button);
border: none;
cursor: pointer;
width: 100%;
padding: 0.75rem 1.5rem;
font-size: var(--t-text-large);
font-weight: 600;
transition: all 0.2s ease;
&:disabled {
background-color: var(--t-border-default);
@ -299,10 +655,35 @@
.accordion-summary {
color: var(--t-text-primary);
display: flex;
justify-content: space-between;
align-items: center;
padding-block: 1rem;
cursor: pointer;
list-style: none;
&::-webkit-details-marker {
display: none;
}
}
.accordion-title {
font-weight: 600;
}
.accordion-icon {
width: 1.25rem;
height: 1.25rem;
transition: transform 0.2s ease;
}
details[open] > .accordion-summary .accordion-icon {
transform: rotate(180deg);
}
.accordion-body {
color: var(--t-text-secondary);
padding-bottom: 1rem;
}
/* ── Product details ── */
@ -311,6 +692,20 @@
border-top: 1px solid var(--t-border-subtle);
border-bottom: 1px solid var(--t-border-subtle);
border-color: var(--t-border-subtle);
margin-top: 2rem;
& > * + * {
border-top: 1px solid var(--t-border-subtle);
}
}
.details-description {
line-height: 1.625;
}
.details-table {
width: 100%;
font-size: var(--t-text-small);
}
.details-table-row {
@ -319,10 +714,29 @@
.details-th {
color: var(--t-text-primary);
text-align: left;
padding-block: 0.5rem;
font-weight: 600;
}
.details-td {
padding-block: 0.5rem;
}
.details-subheading {
color: var(--t-text-primary);
font-weight: 600;
margin-bottom: 0.25rem;
}
.details-shipping {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.details-shipping-text {
font-size: var(--t-text-small);
}
/* ── Announcement bar ── */
@ -784,6 +1198,11 @@
padding: 0.5rem;
cursor: pointer;
color: var(--t-text-secondary);
& svg {
width: 1.25rem;
height: 1.25rem;
}
}
.cart-drawer-items {
@ -829,6 +1248,7 @@
border: none;
cursor: pointer;
font-family: var(--t-font-body);
margin-bottom: 0.5rem;
}
/* ── Cart item row ── */
@ -910,6 +1330,8 @@
.cart-qty-group {
border: 1px solid var(--t-border-default);
border-radius: var(--t-radius-input);
display: flex;
align-items: center;
}
.cart-qty-btn {
@ -917,13 +1339,15 @@
border: none;
cursor: pointer;
color: var(--t-text-primary);
padding: 0.25rem 0.75rem;
}
.cart-qty-display {
border-color: var(--t-border-default);
color: var(--t-text-primary);
min-width: 2rem;
text-align: center;
padding: 0.25rem 0.75rem;
border-inline: 1px solid var(--t-border-default);
}
.cart-qty-text {
@ -933,6 +1357,7 @@
.cart-item-price-col {
flex-shrink: 0;
text-align: right;
}
.cart-item-price {
@ -961,10 +1386,20 @@
.cart-empty {
color: var(--t-text-secondary);
text-align: center;
padding-block: 2rem;
}
.cart-empty-icon {
color: var(--t-text-tertiary);
width: 4rem;
height: 4rem;
margin-inline: auto;
margin-bottom: 1rem;
}
.cart-empty p {
margin-bottom: 1rem;
}
.cart-continue-link {
@ -980,25 +1415,66 @@
/* ── Cart page item (full size) ── */
.cart-page-item {
display: flex;
gap: 1rem;
padding: 1rem;
}
.cart-page-image {
border-radius: var(--t-radius-image);
width: 6rem;
height: 6rem;
flex-shrink: 0;
background-color: #e5e7eb;
overflow: hidden;
& img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.cart-page-item-info {
flex: 1;
}
.cart-page-item-name {
font-family: var(--t-font-heading);
color: var(--t-text-primary);
font-weight: 600;
margin-bottom: 0.25rem;
}
.cart-page-item-variant {
color: var(--t-text-secondary);
font-size: var(--t-text-small);
margin-bottom: 0.5rem;
}
.cart-page-item-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.cart-page-item-remove {
color: var(--t-text-tertiary);
font-size: var(--t-text-small);
background: none;
border: none;
cursor: pointer;
}
.cart-page-item-price-col {
text-align: right;
}
.cart-page-item-price {
color: var(--t-text-primary);
font-weight: 700;
font-size: var(--t-text-large);
}
/* ── Delivery line ── */
@ -1007,12 +1483,21 @@
font-family: var(--t-font-body);
font-size: var(--t-text-small);
color: var(--t-text-secondary);
display: flex;
justify-content: space-between;
align-items: center;
& form {
display: inline;
}
}
.delivery-line-label {
display: flex;
align-items: center;
gap: 0.25rem;
}
.delivery-select {
appearance: auto;
background: transparent;
@ -1027,9 +1512,30 @@
/* ── Order summary ── */
.order-summary-card {
padding: 1.5rem;
position: sticky;
top: 1rem;
}
.order-summary-heading {
font-family: var(--t-font-heading);
color: var(--t-text-primary);
font-size: var(--t-text-xl);
font-weight: 700;
margin-bottom: 1.5rem;
}
.order-summary-lines {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.order-summary-line {
display: flex;
justify-content: space-between;
}
.order-summary-label {
@ -1041,7 +1547,34 @@
}
.order-summary-divider {
border-color: var(--t-border-default);
border-top: 1px solid var(--t-border-default);
padding-top: 0.75rem;
}
.order-summary-total {
display: flex;
justify-content: space-between;
font-size: var(--t-text-large);
}
.order-summary-checkout {
width: 100%;
padding: 0.75rem 1.5rem;
font-weight: 600;
transition: all 0.2s ease;
}
.order-summary-checkout-form {
margin-bottom: 0.75rem;
}
.order-summary-continue {
display: block;
width: 100%;
padding: 0.75rem 1.5rem;
font-weight: 600;
transition: all 0.2s ease;
text-align: center;
}
/* ── Content body ── */
@ -1498,6 +2031,42 @@
object-fit: cover;
}
/* ── Product grid ── */
.product-grid {
display: grid;
}
.product-grid[data-columns="fixed-4"] {
grid-template-columns: repeat(2, 1fr);
}
.product-grid:not([data-columns="fixed-4"]) {
grid-template-columns: 1fr;
}
/* ── Cart layout (cart page) ── */
.cart-layout {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
.cart-items-stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ── Error page product grid ── */
.error-container .product-grid {
margin-top: 3rem;
max-width: 36rem;
margin-inline: auto;
}
/* ── Screen reader only ── */
.sr-only {
@ -1516,9 +2085,14 @@
@media (min-width: 640px) {
.page-container { padding-inline: 1.5rem; }
.collection-header-inner { padding-inline: 1.5rem; }
.shop-header { padding: 0.75rem 1rem; }
.shop-footer-inner { padding-inline: 1.5rem; }
.search-kbd { display: flex; }
.product-grid:not([data-columns="fixed-4"]) {
grid-template-columns: repeat(2, 1fr);
}
.hero-cta-group { flex-direction: row; }
}
@media (min-width: 768px) {
@ -1530,11 +2104,33 @@
.footer-bottom { flex-direction: row; }
.pdp-grid { grid-template-columns: repeat(2, 1fr); }
.contact-grid { grid-template-columns: repeat(2, 1fr); }
.product-grid[data-columns="fixed-4"] {
grid-template-columns: repeat(4, 1fr);
}
.image-text-section { grid-template-columns: repeat(2, 1fr); }
.collection-header-wrap .t-heading { font-size: var(--t-heading-xl); }
.hero-section .t-heading { font-size: var(--t-heading-xl); }
.hero-section--page .t-heading { font-size: var(--t-heading-display); }
.hero-error .t-heading { font-size: var(--t-heading-xl); }
.pdp-price-row .product-price--sale,
.pdp-price-row .product-price--regular { font-size: var(--t-heading-xl); }
.atc-wrap[data-sticky="true"] {
position: relative;
padding-block: 0;
margin-inline: 0;
padding-inline: 0;
border-top: none;
}
}
@media (min-width: 1024px) {
.page-container { padding-inline: 2rem; }
.collection-header-inner { padding-inline: 2rem; }
.shop-footer-inner { padding-inline: 2rem; }
.cart-grid { grid-template-columns: 2fr 1fr; }
.cart-layout { grid-template-columns: 2fr 1fr; }
.product-grid[data-columns="2"] { grid-template-columns: repeat(2, 1fr); }
.product-grid[data-columns="3"] { grid-template-columns: repeat(3, 1fr); }
.product-grid[data-columns="4"] { grid-template-columns: repeat(4, 1fr); }
}
}

View File

@ -85,15 +85,15 @@
}
/* Layout Width */
&[data-layout="contained"] .max-w-7xl {
&[data-layout="contained"] :is(.max-w-7xl, .page-container, .collection-header-inner) {
max-width: var(--t-layout-max-width, 1100px);
}
&[data-layout="wide"] .max-w-7xl {
&[data-layout="wide"] :is(.max-w-7xl, .page-container, .collection-header-inner) {
max-width: var(--t-layout-max-width, 1400px);
}
&[data-layout="full"] .max-w-7xl {
&[data-layout="full"] :is(.max-w-7xl, .page-container, .collection-header-inner) {
max-width: var(--t-layout-max-width, 100%);
padding-left: 2rem;
padding-right: 2rem;
@ -175,7 +175,7 @@
}
/* Image Aspect Ratio */
& .product-card .product-image-container {
& .product-card .product-card-image-wrap {
aspect-ratio: var(--t-image-aspect-ratio, 1 / 1);
}
@ -330,7 +330,7 @@
}
/* Product Card Images — mobile: swipe, desktop: hover crossfade */
.product-image-container {
.product-card-image-wrap {
position: relative;
}

View File

@ -18,7 +18,7 @@
mode={@mode}
/>
<.product_grid columns={:fixed_4} gap="gap-4" class="mt-12 max-w-xl mx-auto">
<.product_grid columns={:fixed_4}>
<%= for product <- Enum.take(assigns[:products] || [], 4) do %>
<.product_card
product={product}

View File

@ -92,7 +92,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
aria-label="Close cart"
>
<svg
class="w-5 h-5"
class="cart-drawer-close-icon"
width="20"
height="20"
viewBox="0 0 24 24"
@ -134,7 +134,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
<%= if @mode == :preview do %>
<button
type="button"
class="cart-drawer-checkout w-full mb-2"
class="cart-drawer-checkout"
>
Checkout
</button>
@ -143,7 +143,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<button
type="submit"
class="cart-drawer-checkout w-full mb-2"
class="cart-drawer-checkout"
>
Checkout
</button>
@ -213,24 +213,24 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
<div class="cart-item-actions">
<%= if @show_quantity_controls do %>
<div class="cart-qty-group flex items-center">
<div class="cart-qty-group">
<button
type="button"
phx-click="decrement"
phx-value-id={@item.variant_id}
class="cart-qty-btn px-3 py-1"
class="cart-qty-btn"
aria-label={"Decrease quantity of #{@item.name}"}
>
</button>
<span class="cart-qty-display px-3 py-1 border-x">
<span class="cart-qty-display">
{@item.quantity}
</span>
<button
type="button"
phx-click="increment"
phx-value-id={@item.variant_id}
class="cart-qty-btn px-3 py-1"
class="cart-qty-btn"
aria-label={"Increase quantity of #{@item.name}"}
>
+
@ -246,7 +246,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
</div>
</div>
<div class="cart-item-price-col text-right">
<div class="cart-item-price-col">
<p class="cart-item-price" data-size={if @size == :compact, do: "compact"}>
{SimpleshopTheme.Cart.format_price(@item.price * @item.quantity)}
</p>
@ -262,9 +262,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
def cart_empty_state(assigns) do
~H"""
<div class="cart-empty text-center py-8">
<div class="cart-empty">
<svg
class="cart-empty-icon w-16 h-16 mx-auto mb-4"
class="cart-empty-icon"
width="64"
height="64"
viewBox="0 0 24 24"
@ -276,7 +276,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
<line x1="3" y1="6" x2="21" y2="6"></line>
<path d="M16 10a4 4 0 01-8 0"></path>
</svg>
<p class="mb-4">Your basket is empty</p>
<p>Your basket is empty</p>
<%= if @mode == :preview do %>
<button
type="button"
@ -334,43 +334,42 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
def cart_item(assigns) do
~H"""
<.shop_card class="flex gap-4 p-4">
<div class="cart-page-image w-24 h-24 flex-shrink-0 bg-gray-200 overflow-hidden">
<.shop_card class="cart-page-item">
<div class="cart-page-image">
<img
src={cart_item_image(@item.product)}
alt={@item.product.title}
width="96"
height="96"
loading="lazy"
class="w-full h-full object-cover"
/>
</div>
<div class="flex-1">
<h3 class="cart-page-item-name font-semibold mb-1">
<div class="cart-page-item-info">
<h3 class="cart-page-item-name">
{@item.product.title}
</h3>
<p class="cart-page-item-variant text-sm mb-2">
<p class="cart-page-item-variant">
{@item.variant}
</p>
<div class="flex items-center gap-4">
<div class="cart-qty-group flex items-center">
<button class="cart-qty-btn px-3 py-1"></button>
<span class="cart-qty-display px-3 py-1 border-x">
<div class="cart-page-item-actions">
<div class="cart-qty-group">
<button class="cart-qty-btn"></button>
<span class="cart-qty-display">
{@item.quantity}
</span>
<button class="cart-qty-btn px-3 py-1">+</button>
<button class="cart-qty-btn">+</button>
</div>
<button class="cart-page-item-remove text-sm">
<button class="cart-page-item-remove">
Remove
</button>
</div>
</div>
<div class="text-right">
<p class="cart-page-item-price font-bold text-lg">
<div class="cart-page-item-price-col">
<p class="cart-page-item-price">
{SimpleshopTheme.Cart.format_price(@item.product.cheapest_price * @item.quantity)}
</p>
</div>
@ -391,8 +390,8 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
defp delivery_line(assigns) do
~H"""
<div class="delivery-line flex justify-between items-center">
<span class="flex items-center gap-1">
<div class="delivery-line">
<span class="delivery-line-label">
Delivery to
<%= if @available_countries != [] and @mode != :preview do %>
<form phx-change="change_country">
@ -445,15 +444,15 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
assign(assigns, :estimated_total, assigns.subtotal + (assigns.shipping_estimate || 0))
~H"""
<.shop_card class="p-6 sticky top-4">
<h2 class="order-summary-heading text-xl font-bold mb-6">
<.shop_card class="order-summary-card">
<h2 class="order-summary-heading">
Order summary
</h2>
<div class="flex flex-col gap-3 mb-6">
<div class="flex justify-between">
<span class="order-summary-label">Subtotal</span>
<span class="order-summary-value">
<div class="order-summary-lines">
<div class="order-summary-line">
<span>Subtotal</span>
<span>
{SimpleshopTheme.Cart.format_price(@subtotal)}
</span>
</div>
@ -463,12 +462,12 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
available_countries={@available_countries}
mode={@mode}
/>
<div class="order-summary-divider border-t pt-3">
<div class="flex justify-between text-lg">
<span class="order-summary-value font-semibold">
<div class="order-summary-divider">
<div class="order-summary-total">
<span>
{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}
</span>
<span class="order-summary-value font-bold">
<span>
{SimpleshopTheme.Cart.format_price(@estimated_total)}
</span>
</div>
@ -476,26 +475,26 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
</div>
<%= if @mode == :preview do %>
<.shop_button class="w-full px-6 py-3 font-semibold transition-all mb-3">
<.shop_button class="order-summary-checkout">
Checkout
</.shop_button>
<.shop_button_outline
phx-click="change_preview_page"
phx-value-page="collection"
class="w-full px-6 py-3 font-semibold transition-all"
class="order-summary-continue"
>
Continue shopping
</.shop_button_outline>
<% else %>
<form action="/checkout" method="post" class="mb-3">
<form action="/checkout" method="post" class="order-summary-checkout-form">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<.shop_button type="submit" class="w-full px-6 py-3 font-semibold transition-all">
<.shop_button type="submit" class="order-summary-checkout">
Checkout
</.shop_button>
</form>
<.shop_link_outline
href="/collections/all"
class="block w-full px-6 py-3 font-semibold transition-all text-center"
class="order-summary-continue"
>
Continue shopping
</.shop_link_outline>
@ -524,14 +523,12 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
def cart_layout(assigns) do
~H"""
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2">
<div class="flex flex-col gap-4">
<div class="cart-layout">
<div class="cart-items-stack">
<%= for item <- @items do %>
<.cart_item item={item} />
<% end %>
</div>
</div>
<div>
<.order_summary subtotal={@subtotal} mode={@mode} />

View File

@ -62,7 +62,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
~H"""
<article
class={card_classes(@variant)}
class="product-card"
data-variant={@variant}
data-clickable={@clickable_resolved || nil}
>
@ -103,7 +103,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|> assign(:has_hover_image, assigns.theme_settings.hover_image && hover_image != nil)
~H"""
<div class={["product-card-image-wrap", image_container_classes(@variant)]}>
<div class="product-card-image-wrap">
<%= if @show_badges do %>
<.product_badge product={@product} />
<% end %>
@ -118,13 +118,13 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
alt={@product.title}
variant={@variant}
priority={@priority}
class="product-image-primary w-full h-full object-cover transition-opacity duration-300"
class="product-image-primary"
/>
<.product_card_image
image={@hover_image}
alt={@product.title}
variant={@variant}
class="product-image-hover w-full h-full object-cover"
class="product-image-hover"
/>
</div>
<% else %>
@ -133,7 +133,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
alt={@product.title}
variant={@variant}
priority={@priority}
class="product-image-primary w-full h-full object-cover"
class="product-image-primary"
/>
<% end %>
<%= if @has_hover_image do %>
@ -143,22 +143,22 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
</div>
<% end %>
</div>
<div class={content_padding_class(@variant)}>
<div class="product-card-content">
<%= if @show_category && Map.get(@product, :category) do %>
<%= if @mode == :preview do %>
<p class="product-card-category text-xs mb-1">
<p class="product-card-category">
{@product.category}
</p>
<% else %>
<.link
navigate={"/collections/#{Slug.slugify(@product.category)}"}
class="product-card-category text-xs mb-1 block hover:underline"
class="product-card-category"
>
{@product.category}
</.link>
<% end %>
<% end %>
<h3 class={["product-card-title", title_classes(@variant)]}>
<h3 class="product-card-title">
<%= if @clickable do %>
<%= if @mode == :preview do %>
<a
@ -185,7 +185,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
<.product_price product={@product} variant={@variant} />
<% end %>
<%= if @show_delivery_text do %>
<p class="product-card-delivery text-xs mt-1">
<p class="product-card-delivery">
Made to order
</p>
<% end %>
@ -226,7 +226,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
<%= cond do %>
<% is_nil(@src) -> %>
<div
class={[@class, "product-card-placeholder flex items-center justify-center"]}
class={[@class, "product-card-placeholder"]}
role="img"
aria-label={@alt}
>
@ -238,7 +238,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
stroke="currentColor"
width="48"
height="48"
class="size-12 opacity-40"
class="placeholder-icon"
>
<path
stroke-linecap="round"
@ -297,33 +297,33 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
<% :default -> %>
<div>
<%= if @product.on_sale do %>
<span class="product-price--sale text-lg font-bold">
<span class="product-price--sale">
{SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
</span>
<span class="product-price--compare text-sm line-through ml-2">
<span class="product-price--compare">
{SimpleshopTheme.Cart.format_price(@product.compare_at_price)}
</span>
<% else %>
<span class="product-price--regular text-lg font-bold">
<span class="product-price--regular">
{SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
</span>
<% end %>
</div>
<% :featured -> %>
<p class="product-price--secondary text-sm">
<p class="product-price--secondary">
<%= if @product.on_sale do %>
<span class="product-price--compare line-through mr-1">
<span class="product-price--compare">
{SimpleshopTheme.Cart.format_price(@product.compare_at_price)}
</span>
<% end %>
{SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
</p>
<% :compact -> %>
<p class="product-price--regular font-bold">
<p class="product-price--regular">
{SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
</p>
<% :minimal -> %>
<p class="product-price--secondary text-xs">
<p class="product-price--secondary">
{SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
</p>
<% end %>
@ -342,32 +342,6 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
defp variant_defaults(:minimal),
do: %{show_category: false, show_badges: false, show_delivery_text: false, clickable: false}
defp card_classes(:default), do: "product-card group overflow-hidden transition-all"
defp card_classes(:featured),
do: "product-card group overflow-hidden transition-all hover:-translate-y-1"
defp card_classes(:compact), do: "product-card group overflow-hidden cursor-pointer"
defp card_classes(:minimal), do: "product-card group overflow-hidden"
defp image_container_classes(:compact),
do: "product-image-container aspect-square bg-gray-200 overflow-hidden relative"
defp image_container_classes(:minimal),
do: "product-image-container aspect-square bg-gray-200 overflow-hidden relative"
defp image_container_classes(_),
do: "product-image-container bg-gray-200 overflow-hidden relative"
defp content_padding_class(:compact), do: "p-3"
defp content_padding_class(:minimal), do: "p-2"
defp content_padding_class(_), do: ""
defp title_classes(:default), do: "font-semibold mb-2"
defp title_classes(:featured), do: "text-sm font-medium mb-1"
defp title_classes(:compact), do: "font-semibold text-sm mb-1"
defp title_classes(:minimal), do: "text-xs font-semibold truncate"
@doc """
Renders a responsive product grid container.
@ -394,57 +368,33 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
<% end %>
</.product_grid>
<.product_grid columns={:fixed_4} gap="gap-6">
<.product_grid columns={:fixed_4}>
...
</.product_grid>
"""
attr :theme_settings, :map, default: nil
attr :columns, :atom, default: nil
attr :gap, :string, default: nil
attr :class, :string, default: nil
slot :inner_block, required: true
def product_grid(assigns) do
cols =
cond do
assigns.columns == :fixed_4 -> "fixed-4"
assigns.theme_settings != nil -> assigns.theme_settings.grid_columns || "3"
true -> "3"
end
assigns = assign(assigns, :data_columns, cols)
~H"""
<div class={grid_classes(@theme_settings, @columns, @gap, @class)}>
<div class={["product-grid", @class]} data-columns={@data_columns}>
{render_slot(@inner_block)}
</div>
"""
end
defp grid_classes(theme_settings, columns, gap, extra_class) do
base = "product-grid grid"
cols =
cond do
columns == :fixed_4 ->
"grid-cols-2 md:grid-cols-4"
theme_settings != nil ->
responsive_cols = "grid-cols-1 sm:grid-cols-2"
lg_cols =
case theme_settings.grid_columns do
"2" -> "lg:grid-cols-2"
"3" -> "lg:grid-cols-3"
"4" -> "lg:grid-cols-4"
_ -> "lg:grid-cols-3"
end
"#{responsive_cols} #{lg_cols}"
true ->
"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
end
gap_class = gap || ""
[base, cols, gap_class, extra_class]
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|> Enum.join(" ")
end
@doc """
Renders a centered hero section with title, description, and optional CTAs.
@ -509,11 +459,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
~H"""
<%= case @variant do %>
<% :default -> %>
<section class="hero-section text-center" data-background={@background}>
<h1 class="t-heading text-3xl md:text-4xl mb-4">
<section class="hero-section" data-background={@background}>
<h1 class="t-heading">
{@title}
</h1>
<p class="hero-description text-lg max-w-lg mx-auto mb-8">
<p class="hero-description">
{@description}
</p>
<.hero_cta
@ -526,29 +476,29 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
/>
</section>
<% :page -> %>
<div class="hero-section--page text-center">
<h1 class="t-heading text-4xl md:text-5xl mb-6">
<div class="hero-section--page">
<h1 class="t-heading">
{@title}
</h1>
<p class="hero-description text-lg mb-12 max-w-2xl mx-auto">
<p class="hero-description">
{@description}
</p>
</div>
<% :error -> %>
<div class="text-center">
<div class="hero-error">
<%= if @pre_title do %>
<h1 class="hero-pre-title text-8xl md:text-9xl mb-4">
<h1 class="hero-pre-title">
{@pre_title}
</h1>
<% end %>
<h2 class="t-heading text-3xl md:text-4xl mb-6">
<h2 class="t-heading">
{@title}
</h2>
<p class="hero-description text-lg mb-8 max-w-md mx-auto">
<p class="hero-description">
{@description}
</p>
<%= if @cta_text || @secondary_cta_text do %>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<div class="hero-cta-group">
<.hero_cta
:if={@cta_text}
text={@cta_text}
@ -579,19 +529,27 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
attr :variant, :atom, required: true
defp hero_cta(assigns) do
base_class =
case assigns.variant do
:primary -> "themed-button hero-cta"
:secondary -> "themed-button-outline hero-cta"
end
assigns = assign(assigns, :cta_class, base_class)
~H"""
<%= if @mode == :preview do %>
<button
phx-click="change_preview_page"
phx-value-page={@page}
class={hero_cta_classes(@variant)}
class={@cta_class}
>
{@text}
</button>
<% else %>
<.link
navigate={@href || "/"}
class={["inline-block", hero_cta_classes(@variant)]}
class={@cta_class}
>
{@text}
</.link>
@ -599,11 +557,6 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
"""
end
defp hero_cta_classes(:primary), do: "themed-button px-8 py-3 font-semibold transition-all"
defp hero_cta_classes(:secondary),
do: "themed-button-outline px-8 py-3 font-semibold transition-all"
@doc """
Renders a row of category circles for navigation.
@ -626,17 +579,17 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
~H"""
<section class="category-nav-section">
<h2 class="sr-only">Shop by Category</h2>
<nav class="grid grid-cols-3 gap-4 max-w-3xl mx-auto" aria-label="Product categories">
<nav class="category-nav" aria-label="Product categories">
<%= for category <- Enum.take(@categories, @limit) do %>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="collection"
class="category-card flex flex-col items-center gap-3 p-4 rounded-lg transition-colors hover:bg-black/5"
class="category-card"
>
<div
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105"
class="category-image"
style={
if(category[:image_url],
do: "background-image: url('#{category.image_url}');",
@ -645,17 +598,17 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
}
>
</div>
<span class="category-name text-sm font-medium">
<span class="category-name">
{category.name}
</span>
</a>
<% else %>
<.link
navigate={"/collections/#{category.slug}"}
class="category-card flex flex-col items-center gap-3 p-4 rounded-lg transition-colors hover:bg-black/5"
class="category-card"
>
<div
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105"
class="category-image"
style={
if(category[:image_url],
do: "background-image: url('#{category.image_url}');",
@ -664,7 +617,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
}
>
</div>
<span class="category-name text-sm font-medium">
<span class="category-name">
{category.name}
</span>
</.link>
@ -710,7 +663,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
def featured_products_section(assigns) do
~H"""
<section class="featured-section">
<h2 class="t-heading text-2xl mb-6">
<h2 class="t-heading">
{@title}
</h2>
@ -726,19 +679,19 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
<% end %>
</.product_grid>
<div class="text-center mt-8">
<div class="featured-cta-wrap">
<%= if @mode == :preview do %>
<button
phx-click="change_preview_page"
phx-value-page={@cta_page}
class="outline-button px-6 py-3 font-medium transition-all"
class="outline-button"
>
{@cta_text}
</button>
<% else %>
<.link
navigate={@cta_href}
class="outline-button inline-block px-6 py-3 font-medium transition-all"
class="outline-button"
>
{@cta_text}
</.link>
@ -784,7 +737,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
def image_text_section(assigns) do
~H"""
<section class="image-text-section grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
<section class="image-text-section">
<%= if @image_position == :left do %>
<.image_text_image image_url={@image_url} />
<.image_text_content
@ -815,7 +768,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
defp image_text_image(assigns) do
~H"""
<div
class="image-text-image h-72 bg-cover bg-center"
class="image-text-image"
style={"background-image: url('#{@image_url}');"}
>
</div>
@ -832,10 +785,10 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
defp image_text_content(assigns) do
~H"""
<div>
<h2 class="t-heading text-2xl mb-4">
<h2 class="t-heading">
{@title}
</h2>
<p class="image-text-body text-base mb-4">
<p class="image-text-body">
{@description}
</p>
<%= if @link_text do %>
@ -844,14 +797,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
href="#"
phx-click="change_preview_page"
phx-value-page={@link_page}
class="accent-link text-sm font-medium transition-colors"
class="accent-link"
>
{@link_text}
</a>
<% else %>
<.link
navigate={@link_href || "/"}
class="accent-link text-sm font-medium transition-colors"
class="accent-link"
>
{@link_text}
</.link>
@ -880,9 +833,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
def collection_header(assigns) do
~H"""
<div class="collection-header-wrap border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="t-heading text-3xl md:text-4xl mb-2">
<div class="collection-header-wrap">
<div class="collection-header-inner">
<h1 class="t-heading">
{@title}
</h1>
<%= if @subtitle do %>
@ -924,9 +877,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
def filter_bar(assigns) do
~H"""
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="filter-bar">
<!-- Category Pills -->
<div class="filter-pills-container flex gap-2 overflow-x-auto">
<div class="filter-pills-container">
<button class={"filter-pill#{if @active_category == "All", do: " filter-pill-active", else: ""}"}>
All
</button>
@ -938,7 +891,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
</div>
<!-- Sort Dropdown -->
<.shop_select options={@sort_options} class="px-4 py-2" />
<.shop_select options={@sort_options} />
</div>
"""
end
@ -980,12 +933,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
href="#"
phx-click="change_preview_page"
phx-value-page={item.page}
class="hover:underline"
>
{item.label}
</a>
<% else %>
<.link navigate={item.href || "/"} class="hover:underline">{item.label}</.link>
<.link navigate={item.href || "/"}>{item.label}</.link>
<% end %>
</li>
<% end %>
@ -1022,12 +974,12 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
def related_products_section(assigns) do
~H"""
<div class="related-section py-12">
<h2 class="t-heading text-2xl mb-6">
<div class="related-section">
<h2 class="t-heading">
{@title}
</h2>
<.product_grid columns={:fixed_4} gap="gap-6">
<.product_grid columns={:fixed_4}>
<%= for product <- Enum.take(@products, @limit) do %>
<.product_card
product={product}
@ -1062,7 +1014,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
~H"""
<div class="pdp-gallery">
<%!-- Image area (relative container for dots + desktop nav) --%>
<div class="pdp-gallery-frame relative">
<div class="pdp-gallery-frame">
<%!-- Scroll-snap carousel (2+ images) or single image --%>
<%= if length(@images) > 1 do %>
<div
@ -1129,7 +1081,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
<div class="pdp-gallery-single">
<%= if @images == [] do %>
<div
class="product-card-placeholder w-full h-full flex items-center justify-center"
class="product-card-placeholder"
role="img"
aria-label={@product_name}
>
@ -1141,7 +1093,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
stroke="currentColor"
width="48"
height="48"
class="size-12 opacity-40"
class="placeholder-icon"
>
<path
stroke-linecap="round"
@ -1156,7 +1108,6 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
alt={@product_name}
width="600"
height="600"
class="w-full h-full object-cover"
/>
<div
class="pdp-lightbox-click"
@ -1184,7 +1135,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
<%= for {url, idx} <- Enum.with_index(@images) do %>
<button
type="button"
class={"aspect-square bg-gray-200 overflow-hidden pdp-thumbnail#{if idx == 0, do: " pdp-thumbnail-active", else: ""}"}
class={"pdp-thumbnail#{if idx == 0, do: " pdp-thumbnail-active", else: ""}"}
aria-label={"View image #{idx + 1} of #{length(@images)}"}
phx-click={
Phoenix.LiveView.JS.dispatch("pdp:scroll-to",
@ -1207,7 +1158,6 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
width="150"
height="150"
loading="lazy"
class="w-full h-full object-cover"
/>
</button>
<% end %>
@ -1337,23 +1287,23 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
~H"""
<div>
<h1 class="t-heading text-3xl md:text-4xl mb-4">
<h1 class="t-heading pdp-title">
{@product.title}
</h1>
<div class="flex items-center gap-4 mb-6">
<div class="pdp-price-row">
<%= if @product.on_sale do %>
<span class="product-price--sale text-3xl font-bold">
<span class="product-price--sale">
{SimpleshopTheme.Cart.format_price(@price)}
</span>
<span class="product-price--compare text-xl line-through">
<span class="product-price--compare">
{SimpleshopTheme.Cart.format_price(@product.compare_at_price)}
</span>
<span class="sale-badge px-2 py-1 text-sm font-bold text-white rounded">
<span class="sale-badge">
SAVE {round((@product.compare_at_price - @price) / @product.compare_at_price * 100)}%
</span>
<% else %>
<span class="product-price--regular text-3xl font-bold">
<span class="product-price--regular">
{SimpleshopTheme.Cart.format_price(@price)}
</span>
<% end %>
@ -1390,14 +1340,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
def variant_selector(assigns) do
~H"""
<div class="mb-6">
<div class="variant-label block font-semibold mb-2">
<div class="variant-selector">
<div class="variant-label">
{@option_type.name}<span
:if={@selected}
class="variant-label-value"
>: {@selected}</span>
</div>
<div class="flex flex-wrap gap-2">
<div class="variant-options">
<%= if @option_type.type == :color do %>
<.color_swatch
:for={value <- @option_type.values}
@ -1437,10 +1387,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
phx-click={if @mode == :shop, do: "select_option"}
phx-value-option={@option_name}
phx-value-selected={@title}
class={[
"color-swatch w-10 h-10 rounded-full border-2 transition-all relative",
@selected && "ring-2 ring-offset-2"
]}
class="color-swatch"
style={"background-color: #{@hex};"}
title={@title}
aria-label={"Select #{@title}"}
@ -1463,7 +1410,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
phx-click={if @mode == :shop, do: "select_option"}
phx-value-option={@option_name}
phx-value-selected={@title}
class="size-btn px-4 py-2 font-medium transition-all"
class="size-btn"
aria-pressed={to_string(@selected)}
>
{@title}
@ -1493,22 +1440,22 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
def quantity_selector(assigns) do
~H"""
<div class="mb-8">
<label class="qty-label block font-semibold mb-2">
<div class="quantity-selector">
<label class="qty-label">
Quantity
</label>
<div class="flex items-center gap-4">
<div class="qty-group flex items-center">
<div class="qty-row">
<div class="qty-group">
<button
type="button"
phx-click="decrement_quantity"
disabled={@quantity <= @min}
aria-label="Decrease quantity"
class="qty-btn px-4 py-2 disabled:opacity-30"
class="qty-btn"
>
</button>
<span class="qty-display px-4 py-2 border-x-2 min-w-12 text-center tabular-nums">
<span class="qty-display">
{@quantity}
</span>
<button
@ -1516,15 +1463,15 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
phx-click="increment_quantity"
disabled={@quantity >= @max}
aria-label="Increase quantity"
class="qty-btn px-4 py-2 disabled:opacity-30"
class="qty-btn"
>
+
</button>
</div>
<%= if @in_stock do %>
<span class="stock-in text-sm">In stock</span>
<span class="stock-in">In stock</span>
<% else %>
<span class="stock-out text-sm font-semibold">Out of stock</span>
<span class="stock-out">Out of stock</span>
<% end %>
</div>
</div>
@ -1553,16 +1500,12 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
def add_to_cart_button(assigns) do
~H"""
<div class={[
"atc-wrap mb-4",
@sticky &&
"sticky bottom-0 z-10 py-3 md:relative md:py-0 -mx-4 px-4 md:mx-0 md:px-0 border-t md:border-0"
]}>
<div class="atc-wrap" data-sticky={to_string(@sticky)}>
<button
type="button"
phx-click={if @mode == :preview, do: open_cart_drawer_js(), else: "add_to_cart"}
disabled={@disabled}
class="atc-btn w-full px-6 py-4 text-lg font-semibold transition-all"
class="atc-btn"
>
{@text}
</button>
@ -1599,11 +1542,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
def accordion_item(assigns) do
~H"""
<details open={@open} class="group">
<summary class="accordion-summary flex justify-between items-center py-4 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<span class="font-semibold">{@title}</span>
<details open={@open}>
<summary class="accordion-summary">
<span class="accordion-title">{@title}</span>
<svg
class="w-5 h-5 transition-transform duration-200 group-open:rotate-180"
class="accordion-icon"
width="20"
height="20"
fill="none"
@ -1613,7 +1556,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="accordion-body pb-4">
<div class="accordion-body">
{render_slot(@inner_block)}
</div>
</details>
@ -1651,25 +1594,25 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
end)
~H"""
<div class="details-wrap mt-8 divide-y">
<div class="details-wrap">
<.accordion_item title="Description" open={true}>
<p class="leading-relaxed">
<p class="details-description">
{@product.description}. Crafted with attention to detail and quality materials, this product is designed to last. Perfect for everyday use or special occasions.
</p>
</.accordion_item>
<%= if @show_size_guide do %>
<.accordion_item title="Size Guide">
<table class="w-full text-sm">
<table class="details-table">
<thead>
<tr class="details-table-row">
<th class="details-th text-left py-2 font-semibold">
<th class="details-th">
Size
</th>
<th class="details-th text-left py-2 font-semibold">
<th class="details-th">
Chest (cm)
</th>
<th class="details-th text-left py-2 font-semibold">
<th class="details-th">
Length (cm)
</th>
</tr>
@ -1677,9 +1620,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
<tbody>
<%= for {size_row, idx} <- Enum.with_index(@sizes) do %>
<tr class={idx < length(@sizes) - 1 && "details-table-row"}>
<td class="py-2">{size_row.size}</td>
<td class="py-2">{size_row.chest}</td>
<td class="py-2">{size_row.length}</td>
<td class="details-td">{size_row.size}</td>
<td class="details-td">{size_row.chest}</td>
<td class="details-td">{size_row.length}</td>
</tr>
<% end %>
</tbody>
@ -1688,16 +1631,16 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
<% end %>
<.accordion_item title="Shipping & Returns">
<div class="flex flex-col gap-3">
<div class="details-shipping">
<div>
<p class="details-subheading font-semibold mb-1">Delivery</p>
<p class="text-sm">
<p class="details-subheading">Delivery</p>
<p class="details-shipping-text">
Free UK delivery on orders over £40. Standard delivery 3-5 working days. Express delivery available at checkout.
</p>
</div>
<div>
<p class="details-subheading font-semibold mb-1">Returns</p>
<p class="text-sm">
<p class="details-subheading">Returns</p>
<p class="details-shipping-text">
We offer a 30-day return policy. Items must be unused and in original packaging. Please contact us to arrange a return.
</p>
</div>