> For the complete documentation index, see [llms.txt](https://docs.rafflex.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.rafflex.io/advanced/twig-templating/custom-twig-product-grid.md).

# Custom Twig Product Grid

Design your own competition cards for the Product grid block using the "Custom Twig (Advanced)" card style, with live countdowns, progress bars and a quick add to basket button.

The **Product grid** block is the main grid of competitions on most sites. It comes with eight ready made card designs, but if you want your cards to look exactly like your brand, you can pick **Custom Twig (Advanced)** and write the card yourself.

You write the design for **one** card. The block then repeats it for every live competition, filling in each competition's own title, image, price, countdown and so on.

{% hint style="info" %}
**Is this for you?** This option is for people comfortable writing HTML and CSS. If that is not you, the built-in card styles cover most needs. You can also copy this page into an AI assistant (such as ChatGPT or Claude), describe the card you want, and ask it to write the template for you.
{% endhint %}

***

### What you get

When you choose **Custom Twig (Advanced)** as the card style:

* A **CSS Styles (loaded once)** box appears for your card styling. It is added to the page once, however many cards there are.
* A **Card HTML + Twig Code** box appears for the card itself. It is used once per competition.
* Each card receives a `product` variable holding everything about that competition (see [Available variables](#available-variables)).
* Your site's colours, logo and social links are available through `settings` (see [Settings Variables](/advanced/twig-templating/settings-variables.md)).
* Ready made live components are available for countdown timers, progress bars and a quick add to basket button (see [Dynamic components](#dynamic-components)).

***

### Set up a custom card

{% stepper %}
{% step %}

#### Open the page

In the **admin panel**, look in the left hand menu under **Content** and click **Pages**. Click **Edit** next to the page you want (usually **Home**).
{% endstep %}

{% step %}

#### Add or find a Product grid block

Scroll to the bottom of the blocks and click **Add to blocks**, then choose **Product grid**. If your page already has a Product grid, you can use that one instead.
{% endstep %}

{% step %}

#### Choose how the grid is split and sorted

Pick an option for **Segmenting Competitions** (**All + Filter** or **Calendar + Categories**) and for **Competition Sorting** (**Ending Soonest at the top.** or **Manual Sort Order**). Both are explained in [How the grid works](#how-the-grid-works) below.
{% endstep %}

{% step %}

#### Choose Custom Twig (Advanced)

Open the **Competition Card Style** dropdown and choose **Custom Twig (Advanced)**. A panel called **Custom Card Template** appears underneath.
{% endstep %}

{% step %}

#### Open the Custom Card Template panel

The panel starts closed. Click it (it says "Click to expand and edit your custom card template") to open it. Inside you will find:

* **CSS Styles (loaded once)**: your card styles. Its helper text reads "Define your card styles here. These are loaded once per page, not per card." This box is plain CSS, so Twig code such as `{{ settings.primary_color }}` is not filled in here. See [Tips and troubleshooting](#tips-and-troubleshooting) for how to use your brand colours in your CSS.
* **Card HTML + Twig Code**: the HTML for a single card.
* **Help**: a quick list of the most used product variables, with a **View Full Documentation** link back to these docs.
  {% endstep %}

{% step %}

#### Add your code

Paste your CSS into **CSS Styles (loaded once)** and your card HTML into **Card HTML + Twig Code**. If you are just starting, copy [Example 1](#example-1-simple-card) below to see it working, then build from there.
{% endstep %}

{% step %}

#### Save and check

Click **Save** at the top right of the page. Then open your live site in a new tab and refresh it to see your cards.
{% endstep %}
{% endstepper %}

{% hint style="warning" %}
**Check your cards while logged in as an admin.** If your template has a mistake, admins see a red **Template Error** box explaining what went wrong. Customers do not see that message, so to them the card simply looks broken. Always check the live page after saving and fix any error straight away.
{% endhint %}

{% hint style="info" %}
Want the same custom cards on another page? Use **Export Blocks** and **Import Blocks** at the top of the page editor to copy your blocks across. See [Copy blocks between pages](/customise-your-site/manage-your-pages.md#copy-blocks-between-pages).
{% endhint %}

***

### How the grid works

Your template controls what goes **inside** each card. The block itself still decides which competitions appear, their order, the tabs or sections around them, and the grid layout.

#### Which competitions appear

The grid shows every competition that is live and publicly listed. A competition is left out if any of these apply:

1. Its **Active** toggle is off.
2. It has a **Start Date (Scheduler)** in the future. It joins the grid by itself once that time arrives. See [Schedule a Competition](/create-your-first-raffle/create-a-competition/schedule-a-competition.md).
3. Its end date has passed.
4. It has sold out.
5. It has **Hidden / VIP Competition** switched on. See [Hidden VIP Competitions](/create-your-first-raffle/hidden-vip-competitions.md).

Because of this, every card in the grid is for a competition customers can enter right now.

{% hint style="info" %}
A competition missing from the grid? See [Why a Competition Is Not on My Homepage](/customise-your-site/set-up-your-homepage/why-a-competition-is-not-on-my-homepage.md) for a step by step checklist.
{% endhint %}

#### Segmenting Competitions

* **All + Filter** shows one grid with a row of tabs above it: **All**, then **Today** (only when at least one competition ends today), then one tab for each [product category](/create-your-first-raffle/product-categories.md) that has a live competition. Customers click a tab to filter the grid.
* **Calendar + Categories** shows stacked sections instead of tabs. First a **Today** section (headed "Today" with the line "Enter Now for a chance to win today's top prizes!") when something ends today, then one section per category, headed with the category name and its description.

Your custom card is used everywhere in the block: in every tab and every section.

#### Competition Sorting

* **Ending Soonest at the top.** puts the competition closing next first.
* **Manual Sort Order** follows the order you set in **Competitions** using **Enable reordering**.

With **Custom Twig (Advanced)**, the sorting you choose is used for every tab and every section, including **Today**.

#### The whole card is a link

Each card is automatically wrapped in a link to that competition's page, so customers can tap anywhere on the card to open it. This has two consequences for your template:

* **Do not add your own links inside the card.** A link inside a link is not valid HTML and breaks the layout. For a "View" or "Enter now" button, use a `<span>` or `<div>` styled to look like a button. The click still opens the competition.
* **Buttons inside the card need to stop the click.** The [Quick Add](#quick-add-to-basket) component shows how.

#### Grid layout

The block arranges your cards for you:

* **Phones:** two cards per row. When the **All + Filter** grid has only one competition to show, that card fills the full width.
* **Tablets and larger:** three cards per row.

Design your card to look good at both sizes. Avoid fixed widths, and use `width: 100%` on images.

#### How quickly changes appear

Changes to your template show as soon as you save. Competition details on the cards (such as tickets sold) refresh automatically, usually within a minute or two. Countdown timers tick down live in the customer's browser.

***

### Available variables

Each card receives a `product` object. Use it like `{{ product.title }}`.

#### The basics

| Variable           | Type   | What it holds                                    | Example                                           |
| ------------------ | ------ | ------------------------------------------------ | ------------------------------------------------- |
| `product.id`       | number | The competition's ID                             | 123                                               |
| `product.title`    | text   | The competition's **Title**                      | "Win a BMW M3"                                    |
| `product.subtitle` | text   | The competition's **Subtitle**, or empty if none | "Competition of the Month"                        |
| `product.slug`     | text   | The last part of the competition's web address   | "win-a-bmw-m3"                                    |
| `product.url`      | text   | The full web address of the competition page     | "<https://yoursite.com/competition/win-a-bmw-m3>" |

#### Images and video

| Variable                          | Type | What it holds                                                            | Example                                        |
| --------------------------------- | ---- | ------------------------------------------------------------------------ | ---------------------------------------------- |
| `product.image_url`               | text | The **Featured Image** address                                           | Full address                                   |
| `product.slider_image_url`        | text | The **Slider Image** address, or empty if none was uploaded              | Full address or empty                          |
| `product.portrait_image_url`      | text | The **Portrait Image** address, or empty if none                         | Full address or empty                          |
| `product.gallery`                 | list | The addresses of the **Gallery** images, in order                        | \["https\://...", "https\://..."]              |
| `product.featured_video_url`      | text | The **Featured Video** (short looping clip) address, or empty if none    | Full address or empty                          |
| `product.video_embed_url`         | text | The player address for the competition's **Video URL**, or empty if none | "<https://www.youtube-nocookie.com/embed/>..." |
| `product.video_embed_type`        | text | "iframe" for YouTube or Vimeo, "video" for a direct video file, or empty | "iframe"                                       |
| `product.video_embed_mime_type`   | text | The file type for a direct video file, otherwise empty                   | "video/mp4"                                    |
| `product.video_embed_orientation` | text | "portrait" for YouTube Shorts, otherwise "landscape", or empty           | "landscape"                                    |

To show a competition's **Video URL** inside a card, see the Video URL Embed example in [Custom Twig Block](/advanced/twig-templating/custom-twig-block.md).

#### Price

| Variable                     | Type   | What it holds                                                                                                                               | Example |
| ---------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `product.price`              | text   | The current ticket price with the currency symbol. Uses the sale price when there is one                                                    | "£1.99" |
| `product.price_raw`          | number | The current ticket price as a plain number, for sums and components                                                                         | 1.99    |
| `product.price_display`      | text   | A friendlier price: "Free" for free competitions, pence (or cents) for prices under 1.00 (such as "50p"), otherwise the same as `price`     | "50p"   |
| `product.original_price`     | text   | The normal (non sale) price with the currency symbol. It is filled in even when there is no sale, so only show it when `is_on_sale` is true | "£2.99" |
| `product.original_price_raw` | number | The normal (non sale) price as a number. Filled in even when there is no sale                                                               | 2.99    |
| `product.is_on_sale`         | yes/no | Whether a sale price is set                                                                                                                 | true    |
| `product.is_free`            | yes/no | Whether the competition is free to enter                                                                                                    | false   |

{% hint style="info" %}
`price_display` can include a leading space for pence prices. Add the `trim` filter to tidy it: `{{ product.price_display|trim }}`.
{% endhint %}

#### Dates and countdowns

| Variable                     | Type   | What it holds                                                                                   | Example                      |
| ---------------------------- | ------ | ----------------------------------------------------------------------------------------------- | ---------------------------- |
| `product.end_date`           | text   | When the competition closes, written for people: "Today", "Tomorrow" or the date, plus the time | "Today 7:00pm"               |
| `product.end_date_raw`       | text   | The closing date and time                                                                       | "2026-01-05 19:00:00"        |
| `product.end_date_seconds`   | number | Seconds until the competition closes. Use this for countdowns                                   | 3600                         |
| `product.draw_date`          | text   | The draw date written for people, or empty if no draw date is set                               | "Draw On Mon Jan 5th 7:00pm" |
| `product.draw_date_raw`      | text   | The draw date and time                                                                          | "2026-01-05 19:00:00"        |
| `product.draw_date_seconds`  | number | Seconds until the draw                                                                          | 86400                        |
| `product.start_date`         | text   | The **Start Date (Scheduler)** written for people, or empty if none                             | "Today 9:00am"               |
| `product.start_date_raw`     | text   | The start date and time, or empty if none                                                       | "2026-01-01 09:00:00"        |
| `product.start_date_seconds` | number | Seconds until the competition opens (0 once it has opened)                                      | 0                            |
| `product.has_started`        | yes/no | Whether the competition has opened                                                              | true                         |

{% hint style="info" %}
The grid only shows competitions that have already opened, so in this block `has_started` is always true and `start_date_seconds` is always 0. They are more useful in the [Custom Twig Block](/advanced/twig-templating/custom-twig-block.md), which can list scheduled competitions.
{% endhint %}

#### Tickets and progress

| Variable                    | Type   | What it holds                                                                                                               | Example |
| --------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------- | ------- |
| `product.max_tickets`       | number | The total number of tickets                                                                                                 | 5000    |
| `product.sold_tickets`      | number | Tickets sold so far                                                                                                         | 3421    |
| `product.tickets_remaining` | number | Tickets still available                                                                                                     | 1579    |
| `product.percent_sold`      | number | The percentage sold, from 0 to 100, to one decimal place                                                                    | 68.4    |
| `product.max_per_user`      | number | The most tickets one customer can buy (0 means no limit is set)                                                             | 50      |
| `product.simple_max_ticket` | yes/no | Whether **Percentage Only** is switched on, meaning you prefer to show the percentage sold rather than exact ticket numbers | false   |

#### Status

| Variable                      | Type   | What it holds                                                                                                      | Example                   |
| ----------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------- |
| `product.has_ended`           | yes/no | Whether the competition has closed or sold out                                                                     | false                     |
| `product.is_sold_out`         | yes/no | Whether the competition is marked as sold out                                                                      | false                     |
| `product.is_active`           | yes/no | Whether the competition's **Active** toggle is on                                                                  | true                      |
| `product.is_featured`         | yes/no | Whether **Add To Slider** is on                                                                                    | true                      |
| `product.has_question`        | yes/no | Whether the competition has an [entry question](/create-your-first-raffle/create-a-competition/entry-questions.md) | false                     |
| `product.question`            | text   | The entry question, or empty if none                                                                               | "What colour is the sky?" |
| `product.auto_draw`           | yes/no | Whether the competition is drawn automatically                                                                     | true                      |
| `product.leaderboard_enabled` | yes/no | Whether the competition has a [leaderboard](/additional-settings/leaderboards.md)                                  | false                     |
| `product.is_leaderboard_only` | yes/no | Whether it is a leaderboard competition with no main draw (its **Draw Winner Quantity** is 0)                      | false                     |

#### Prizes and game

| Variable                         | Type   | What it holds                                                         | Example    |
| -------------------------------- | ------ | --------------------------------------------------------------------- | ---------- |
| `product.cash_alt`               | text   | The **Cash Alternative** with the currency symbol, with no pence      | "£50,000"  |
| `product.cash_alt_raw`           | number | The **Cash Alternative** as a number (0 when none is set)             | 50000      |
| `product.winner_qty`             | number | The **Draw Winner Quantity** (main draw winners, not instant winners) | 1          |
| `product.instant_wins`           | number | The number of instant win prizes in the competition                   | 5          |
| `product.instant_wins_remaining` | number | Instant win prizes not yet won                                        | 3          |
| `product.game_type`              | text   | The competition's **Game Type** (see below)                           | "standard" |

{% hint style="warning" %}
`product.cash_alt` is never empty. A competition with no cash alternative shows "£0". To show a cash alternative only when one is set, check the number instead, as in the [cash alternative pattern](#cash-alternative-only-when-one-is-set) below.
{% endhint %}

The values `game_type` can hold are: `standard`, `sequential`, `spins`, `slots`, `pinata`, `scratch`, `pop`, `safe-cracker`, `loot-boxes`, `instant-reveal`, `pot-drop`, `whack-a-mole`, `one-line-bingo`, `gems`, `plinko` and `coin-flip`.

#### Categories and description

| Variable              | Type | What it holds                                                                                                                                                        | Example               |
| --------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| `product.category`    | text | The name of the competition's first category, or empty if it has none                                                                                                | "Cars"                |
| `product.categories`  | list | The names of all the competition's categories                                                                                                                        | \["Cars", "Featured"] |
| `product.description` | text | Reserved for a short description. It is currently empty for most competitions, so do not rely on it. Customers can read the full description on the competition page | Empty                 |

***

### Dynamic components

These ready made components add live behaviour to your cards without writing any JavaScript. You add them with an `x-data` attribute.

#### Countdown timer

A live countdown to when the competition closes:

```twig
<div x-data="raffleCountdown({{ product.end_date_seconds }})" class="countdown">
  <span x-text="time.days"></span>d
  <span x-text="pad(time.hours)"></span>h
  <span x-text="pad(time.minutes)"></span>m
  <span x-text="pad(time.seconds)"></span>s
</div>
```

| What you can use | What it gives you                                        |
| ---------------- | -------------------------------------------------------- |
| `time.days`      | Whole days left                                          |
| `time.hours`     | Hours left (0 to 23)                                     |
| `time.minutes`   | Minutes left (0 to 59)                                   |
| `time.seconds`   | Seconds left (0 to 59)                                   |
| `hasEnded`       | True once the countdown reaches zero                     |
| `pad(number)`    | Adds a leading zero to single digits, so 5 shows as "05" |

{% hint style="info" %}
Always pass `end_date_seconds` (or `draw_date_seconds` to count down to the draw), not a date. The number of seconds is worked out for you, so the countdown does not depend on the time zone set on your customer's phone or computer. Because competition details are refreshed every minute or two, a countdown can occasionally show slightly more time left than there really is. The competition still closes at its real end time.
{% endhint %}

#### Progress bar

A bar showing how much of the competition has sold:

```twig
<div x-data="raffleProgress({{ product.percent_sold }})" class="progress-bar">
  <div class="progress-fill" :style="'width:' + width + '%'"></div>
</div>
```

| What you can use | What it gives you                                                                            |
| ---------------- | -------------------------------------------------------------------------------------------- |
| `percent`        | The percentage sold, from 0 to 100                                                           |
| `width`          | The width to draw the bar at. Very small amounts are shown as 3% so the bar is still visible |

You style the bar yourself with CSS (see [Example 3](#example-3-full-featured-card)).

#### Quick add to basket

A ticket picker and button that adds tickets to the basket without leaving the page:

```twig
{% if not product.has_question and not product.has_ended %}
<div x-data="raffleQuickAdd({
    productId: {{ product.id }},
    price: {{ product.price_raw }},
    maxTickets: {{ product.max_per_user > 0 ? product.max_per_user : 100 }},
    hasQuestion: {{ product.has_question ? 'true' : 'false' }},
    hasEnded: {{ product.has_ended ? 'true' : 'false' }}
})" @click.prevent.stop>
  <button type="button" @click="decrement" :disabled="tickets <= 1">-</button>
  <span x-text="tickets"></span>
  <button type="button" @click="increment" :disabled="tickets >= maxTickets">+</button>
  <button type="button" @click="addToCart" :disabled="!canAdd">
    <span x-show="!loading">Quick Add</span>
    <span x-show="loading">Adding...</span>
  </button>
  <span x-show="added" x-transition>Added!</span>
  <span x-show="error" x-text="errorMessage"></span>
</div>
{% endif %}
```

{% hint style="warning" %}
**Keep `@click.prevent.stop` on the Quick Add wrapper.** Because the whole card is a link, a click on the plus, minus or add button would otherwise also open the competition page.
{% endhint %}

**Settings you pass in:**

| Setting       | What to pass                                                                          |
| ------------- | ------------------------------------------------------------------------------------- |
| `productId`   | `product.id` (required)                                                               |
| `price`       | `product.price_raw`. Used to choose a sensible starting number of tickets             |
| `maxTickets`  | The highest number the plus button allows. If you leave it out, the limit is 100      |
| `hasQuestion` | `product.has_question`. The add button stays disabled when there is an entry question |
| `hasEnded`    | `product.has_ended`. The add button stays disabled once the competition has closed    |

**What you can use inside it:**

| Name            | What it does                                                             |
| --------------- | ------------------------------------------------------------------------ |
| `tickets`       | The number of tickets currently picked                                   |
| `increment()`   | Adds one ticket (up to `maxTickets`)                                     |
| `decrement()`   | Removes one ticket (down to 1)                                           |
| `addToCart()`   | Adds the tickets to the basket                                           |
| `canAdd`        | True when the add button should be clickable                             |
| `loading`       | True while the tickets are being added                                   |
| `added`         | True for five seconds after a successful add                             |
| `error`         | True if the tickets could not be added                                   |
| `errorMessage`  | The reason the tickets could not be added                                |
| `requiresLogin` | True when the customer needs to log in before they can add these tickets |

**What the customer sees:** after a successful add, a small message such as "10 Tickets added to cart." pops up in the bottom corner of the screen and every basket count on the page updates. If the tickets cannot be added, the same corner message explains why (for example, that they need to log in first). The usual basket checks still apply, so Quick Add can never get round a ticket limit.

{% hint style="info" %}
Competitions with an [entry question](/create-your-first-raffle/create-a-competition/entry-questions.md) cannot use Quick Add, because the customer has to answer the question on the competition page. Wrap Quick Add in the `if` check shown above and show a "View and enter" button instead (see [Example 4](#example-4-card-with-quick-add)).
{% endhint %}

**Starting number of tickets:** Quick Add picks a starting quantity based on the ticket price, so cheap tickets start with a bigger bundle. It never starts above `maxTickets`.

| Ticket price   | Starts at | Ticket price | Starts at |
| -------------- | --------- | ------------ | --------- |
| 20.00 or more  | 2         | 0.20 to 0.29 | 60        |
| 10.00 to 19.99 | 4         | 0.10 to 0.19 | 80        |
| 3.00 to 9.99   | 6         | 0.05 to 0.09 | 160       |
| 1.00 to 2.99   | 10        | 0.03 to 0.04 | 240       |
| 0.50 to 0.99   | 30        | 0.02         | 600       |
| 0.40 to 0.49   | 40        | 0.01         | 1000      |
| 0.30 to 0.39   | 50        | Free         | 3         |

***

### Code examples

Each example has two parts. Paste the CSS into **CSS Styles (loaded once)** and the HTML into **Card HTML + Twig Code**.

#### Example 1: Simple card

{% tabs %}
{% tab title="CSS Styles" %}

```css
.simple-card {
  background: #1a1a2e;
  border-radius: 12px;
  overflow: hidden;
  height: 100%;
}
.simple-card img {
  display: block;
  width: 100%;
  height: 180px;
  object-fit: cover;
}
.simple-card-body {
  padding: 16px;
}
.simple-card h3 {
  color: white;
  font-size: 16px;
  margin: 0 0 8px;
}
.simple-card .price {
  color: #4ade80;
  font-size: 20px;
  font-weight: bold;
}
```

{% endtab %}

{% tab title="Card HTML + Twig Code" %}

```twig
<div class="simple-card">
  <img src="{{ product.image_url }}" alt="{{ product.title }}" loading="lazy">
  <div class="simple-card-body">
    <h3>{{ product.title }}</h3>
    <p class="price">{{ product.price_display|trim }}</p>
  </div>
</div>
```

{% endtab %}
{% endtabs %}

#### Example 2: Card with countdown

{% tabs %}
{% tab title="CSS Styles" %}

```css
.countdown-card {
  background: #16213e;
  border-radius: 12px;
  overflow: hidden;
  height: 100%;
}
.countdown-card img {
  display: block;
  width: 100%;
  height: 200px;
  object-fit: cover;
}
.countdown-card-body {
  padding: 20px;
}
.countdown-card h3 {
  color: white;
  font-size: 18px;
  margin: 0 0 10px;
}
.countdown-card .price {
  color: #4ade80;
  font-size: 24px;
  font-weight: bold;
}
.countdown-card .countdown {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 12px;
}
.countdown-card .countdown span {
  background: #0f0f23;
  color: #9ca3af;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 13px;
}
.countdown-card .ended {
  color: #ef4444;
  font-weight: bold;
}
```

{% endtab %}

{% tab title="Card HTML + Twig Code" %}

```twig
<div class="countdown-card">
  <img src="{{ product.image_url }}" alt="{{ product.title }}" loading="lazy">
  <div class="countdown-card-body">
    <h3>{{ product.title }}</h3>
    <p class="price">{{ product.price }}</p>

    <div x-data="raffleCountdown({{ product.end_date_seconds }})">
      <template x-if="!hasEnded">
        <div class="countdown">
          <span x-text="time.days + 'd'"></span>
          <span x-text="pad(time.hours) + 'h'"></span>
          <span x-text="pad(time.minutes) + 'm'"></span>
          <span x-text="pad(time.seconds) + 's'"></span>
        </div>
      </template>
      <template x-if="hasEnded">
        <p class="ended">Competition Ended</p>
      </template>
    </div>
  </div>
</div>
```

{% endtab %}
{% endtabs %}

#### Example 3: Full featured card

This card shows an instant wins badge, the cash alternative, a sale price, a countdown and a progress bar. It respects **Percentage Only** by hiding the exact ticket numbers when that option is on.

{% tabs %}
{% tab title="CSS Styles" %}

```css
.featured-card {
  background: #1a1a2e;
  border-radius: 12px;
  overflow: hidden;
  position: relative;
  height: 100%;
}
.featured-card img {
  display: block;
  width: 100%;
  height: 200px;
  object-fit: cover;
}
.featured-card-body {
  padding: 20px;
}
.featured-card h3 {
  color: white;
  font-size: 18px;
  margin: 0 0 8px;
  line-height: 1.3;
}
.featured-card .subtitle {
  color: #9ca3af;
  font-size: 13px;
  margin-bottom: 12px;
}
.featured-card .price-row {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-bottom: 12px;
}
.featured-card .price {
  color: #4ade80;
  font-size: 24px;
  font-weight: bold;
}
.featured-card .original-price {
  color: #6b7280;
  text-decoration: line-through;
  font-size: 14px;
}
.featured-card .countdown {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 12px;
}
.featured-card .countdown span {
  background: #0f0f23;
  color: white;
  padding: 6px 10px;
  border-radius: 4px;
  font-size: 13px;
  font-weight: 500;
}
.featured-card .progress-bar {
  height: 6px;
  background: #374151;
  border-radius: 3px;
  overflow: hidden;
}
.featured-card .progress-fill {
  height: 100%;
  background: linear-gradient(90deg, #4ade80, #22c55e);
  border-radius: 3px;
}
.featured-card .stats {
  display: flex;
  justify-content: space-between;
  color: #9ca3af;
  font-size: 12px;
  margin-top: 6px;
}
.featured-card .badge {
  position: absolute;
  top: 12px;
  left: 12px;
  background: #fbbf24;
  color: #000;
  padding: 4px 10px;
  border-radius: 4px;
  font-size: 12px;
  font-weight: bold;
}
.featured-card .cash-alt {
  position: absolute;
  top: 12px;
  right: 12px;
  background: rgba(0, 0, 0, 0.7);
  color: white;
  padding: 4px 10px;
  border-radius: 4px;
  font-size: 12px;
}
```

{% endtab %}

{% tab title="Card HTML + Twig Code" %}

```twig
<div class="featured-card">
  <img src="{{ product.image_url }}" alt="{{ product.title }}" loading="lazy">

  {% if product.instant_wins > 0 %}
    <span class="badge">{{ product.instant_wins }} Instant Wins ({{ product.instant_wins_remaining }} left)</span>
  {% endif %}

  {% if product.cash_alt_raw > 0 %}
    <span class="cash-alt">Cash {{ product.cash_alt }}</span>
  {% endif %}

  <div class="featured-card-body">
    <h3>{{ product.title }}</h3>

    {% if product.subtitle %}
      <p class="subtitle">{{ product.subtitle }}</p>
    {% endif %}

    <div class="price-row">
      <span class="price">{{ product.price }}</span>
      {% if product.is_on_sale %}
        <span class="original-price">{{ product.original_price }}</span>
      {% endif %}
    </div>

    <div x-data="raffleCountdown({{ product.end_date_seconds }})" class="countdown">
      <span x-text="time.days + 'd'"></span>
      <span x-text="pad(time.hours) + 'h'"></span>
      <span x-text="pad(time.minutes) + 'm'"></span>
      <span x-text="pad(time.seconds) + 's'"></span>
    </div>

    <div x-data="raffleProgress({{ product.percent_sold }})" class="progress-bar">
      <div class="progress-fill" :style="'width:' + width + '%'"></div>
    </div>
    <div class="stats">
      {% if product.simple_max_ticket %}
        <span>{{ product.percent_sold|round }}% sold</span>
      {% else %}
        <span>{{ product.sold_tickets|number_format }} / {{ product.max_tickets|number_format }}</span>
        <span>{{ product.percent_sold }}% sold</span>
      {% endif %}
    </div>
  </div>
</div>
```

{% endtab %}
{% endtabs %}

#### Example 4: Card with Quick Add

Competitions without an entry question get a ticket picker and an **Add to Cart** button. Competitions with a question show a "View and Enter" button instead, which opens the competition page.

{% tabs %}
{% tab title="CSS Styles" %}

```css
.quickadd-card {
  background: #1a1a2e;
  border-radius: 12px;
  overflow: hidden;
  position: relative;
  height: 100%;
}
.quickadd-card img {
  display: block;
  width: 100%;
  height: 200px;
  object-fit: cover;
}
.quickadd-card-body {
  padding: 20px;
}
.quickadd-card h3 {
  color: white;
  font-size: 18px;
  margin: 0 0 8px;
}
.quickadd-card .price {
  color: #4ade80;
  font-size: 24px;
  font-weight: bold;
}
.quickadd-card .quick-add {
  margin-top: 12px;
  display: flex;
  align-items: center;
  gap: 8px;
}
.quickadd-card .qty-btn {
  width: 32px;
  height: 32px;
  border: 1px solid #374151;
  background: transparent;
  color: white;
  border-radius: 6px;
  cursor: pointer;
}
.quickadd-card .qty-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
.quickadd-card .qty-display {
  color: white;
  font-size: 16px;
  min-width: 30px;
  text-align: center;
}
.quickadd-card .add-btn {
  flex: 1;
  background: #4ade80;
  color: #000;
  border: none;
  padding: 8px 16px;
  border-radius: 6px;
  font-weight: bold;
  cursor: pointer;
}
.quickadd-card .add-btn:disabled {
  opacity: 0.7;
  cursor: not-allowed;
}
.quickadd-card .add-btn.added {
  background: #22c55e;
}
.quickadd-card .view-btn {
  display: block;
  text-align: center;
  background: #374151;
  color: white;
  padding: 10px;
  border-radius: 6px;
  margin-top: 12px;
}
```

{% endtab %}

{% tab title="Card HTML + Twig Code" %}

```twig
<div class="quickadd-card">
  <img src="{{ product.image_url }}" alt="{{ product.title }}" loading="lazy">

  <div class="quickadd-card-body">
    <h3>{{ product.title }}</h3>
    <p class="price">{{ product.price }}</p>

    {% if not product.has_question and not product.has_ended %}
      {# The wrapper stops clicks here from opening the competition page #}
      <div x-data="raffleQuickAdd({
          productId: {{ product.id }},
          price: {{ product.price_raw }},
          maxTickets: {{ product.max_per_user > 0 ? product.max_per_user : 100 }},
          hasQuestion: false,
          hasEnded: false
      })" class="quick-add" @click.prevent.stop>
        <button type="button" @click="decrement" :disabled="tickets <= 1" class="qty-btn">-</button>
        <span class="qty-display" x-text="tickets"></span>
        <button type="button" @click="increment" :disabled="tickets >= maxTickets" class="qty-btn">+</button>
        <button type="button" @click="addToCart" :disabled="!canAdd" class="add-btn" :class="{ 'added': added }">
          <span x-show="!loading && !added">Add to Cart</span>
          <span x-show="loading">Adding...</span>
          <span x-show="added">Added!</span>
        </button>
      </div>
    {% else %}
      {# The whole card is already a link, so this is a span styled as a button #}
      <span class="view-btn">View and Enter</span>
    {% endif %}
  </div>
</div>
```

{% endtab %}
{% endtabs %}

***

### Common patterns

#### Sale badge

```twig
{% if product.is_on_sale %}
  <span class="sale-badge">SALE</span>
{% endif %}
```

#### Free entry badge

```twig
{% if product.is_free %}
  <span class="free-badge">FREE ENTRY</span>
{% endif %}
```

#### Cash alternative (only when one is set)

```twig
{% if product.cash_alt_raw > 0 %}
  <span class="cash-alt">Or {{ product.cash_alt }} cash</span>
{% endif %}
```

#### Friendly game name

`game_type` holds a short code such as `spins`. Use a lookup list to turn it into a name you choose:

```twig
{% set game_names = {
  'spins': 'Spin to Win',
  'slots': 'Slots',
  'scratch': 'Scratch Card',
  'plinko': 'Plinko'
} %}
{% if game_names[product.game_type] is defined %}
  <span class="game-badge">{{ game_names[product.game_type] }}</span>
{% endif %}
```

#### Multiple winners

```twig
{% if product.winner_qty > 1 %}
  <span>{{ product.winner_qty }} Winners!</span>
{% endif %}
```

#### Almost gone

```twig
{% if product.percent_sold >= 90 %}
  <span class="hot-badge">Almost gone!</span>
{% endif %}
```

#### Optional subtitle

```twig
{% if product.subtitle %}
  <p>{{ product.subtitle }}</p>
{% endif %}
```

#### Looping video instead of the image

If a competition has a **Featured Video**, play it on the card, otherwise show the image:

```twig
{% if product.featured_video_url %}
  <video autoplay loop muted playsinline poster="{{ product.image_url }}" style="width: 100%; display: block;">
    <source src="{{ product.featured_video_url }}">
  </video>
{% else %}
  <img src="{{ product.image_url }}" alt="{{ product.title }}" loading="lazy" style="width: 100%; display: block;">
{% endif %}
```

***

### Tips and troubleshooting

* **Keep CSS in the CSS box.** Styles in **CSS Styles (loaded once)** are added to the page once. Styles written inside the card code are repeated for every competition.
* **Give your classes unique names** (for example `mysite-card` rather than `card`) so they do not clash with the rest of your site.
* **Use your brand colours** from `settings`, for example `{{ settings.primary_color }}`. See [Settings Variables](/advanced/twig-templating/settings-variables.md). These work inside the card code. If you want them in your CSS, set them as CSS custom properties on the card, such as `style="--brand: {{ settings.primary_color }};"`, and use `var(--brand)` in the CSS box.
* **Handle empty values.** Wrap optional details (subtitle, cash alternative, sale price) in an `if`, or give a fallback with `{{ product.subtitle|default('') }}`.
* **Format big numbers** with `number_format`, for example `{{ product.sold_tickets|number_format }}` shows "3,421".
* **Test on a phone.** Cards sit two to a row on phones, so check text is readable and nothing overflows.
* **Use the filters that are available.** Templates support a set list of Twig filters. See [Twig Templating](/advanced/twig-templating.md) for the list.

| Problem                                           | What to check                                                                                                           |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| A red **Template Error** box appears              | Read the message. It usually points to a missing `endif` or `endfor`, or a filter that is not available.                |
| My styles are not applied                         | Check the CSS is in **CSS Styles (loaded once)** and that the class names match the ones in your card code.             |
| A value is blank                                  | Check the spelling of the variable against the tables above, and that the competition has that detail filled in.        |
| The old price shows on a card that is not on sale | Only show `product.original_price` when `product.is_on_sale` is true, as in [Example 3](#example-3-full-featured-card). |
| The cash alternative shows "£0"                   | Only show it when `product.cash_alt_raw > 0` (see [Common patterns](#common-patterns)).                                 |
| Clicking Quick Add opens the competition page     | Make sure the Quick Add wrapper has `@click.prevent.stop`.                                                              |
| The card layout breaks after a button             | Remove any `<a>` links inside the card and use a styled `<span>` instead.                                               |
| A competition is missing                          | It may be inactive, scheduled, ended, sold out or hidden. See [How the grid works](#how-the-grid-works).                |

***

### AI Context

**For AI Assistants:** This documents the Rafflex **Product grid** block's "Custom Twig (Advanced)" card style for creating custom competition cards.

**Block structure:**

* **CSS Styles (loaded once)**: plain CSS added to the page once.
* **Card HTML + Twig Code**: rendered once per competition with a single `product` variable plus `settings`.
* Each rendered card is automatically wrapped in an `<a href="{{ product.url }}">`. Never output `<a>` tags inside the card (use a styled `<span>`). Any interactive control inside the card must sit in a wrapper with `@click.prevent.stop`.
* The block lays cards out in a grid: 2 columns on phones (1 if there is only one competition in the All + Filter view), 3 columns from tablet width. Cards should fill their column (`height: 100%`, `width: 100%` images).
* Only live competitions appear (active, started, not ended, not sold out, not hidden), so `has_started` is always true and `has_ended` is normally false at render time.

**Product fields:** id, title, subtitle, slug, url (absolute), image\_url, slider\_image\_url (empty string if none), portrait\_image\_url, gallery (list of URLs), featured\_video\_url, video\_embed\_url, video\_embed\_type ("iframe" or "video"), video\_embed\_mime\_type, video\_embed\_orientation ("portrait" or "landscape"), price (formatted, uses sale price when set), price\_raw, price\_display ("Free", pence such as " 50p" with a possible leading space, or formatted price), original\_price (formatted normal price, always filled in, so only show it when is\_on\_sale is true), original\_price\_raw (always the normal price), is\_on\_sale, is\_free, has\_question, question, cash\_alt (formatted, never empty, "£0" when none), cash\_alt\_raw (0 when none), end\_date ("Today 7:00pm", "Tomorrow 7:00pm" or "Mon Jan 5th 7:00pm"), end\_date\_raw (Y-m-d H:i:s), end\_date\_seconds, draw\_date ("Draw On Mon Jan 5th 7:00pm" or null), draw\_date\_raw, draw\_date\_seconds, start\_date, start\_date\_raw, start\_date\_seconds, has\_started, max\_tickets, sold\_tickets, tickets\_remaining, percent\_sold (0 to 100, 1 decimal place), max\_per\_user (0 means no limit), simple\_max\_ticket (Percentage Only toggle, prefer showing percentage only when true), has\_ended, is\_sold\_out, is\_active, is\_featured (Add To Slider), auto\_draw, leaderboard\_enabled, is\_leaderboard\_only, winner\_qty, instant\_wins, instant\_wins\_remaining, game\_type (standard, sequential, spins, slots, pinata, scratch, pop, safe-cracker, loot-boxes, instant-reveal, pot-drop, whack-a-mole, one-line-bingo, gems, plinko, coin-flip), category (first category name), categories (list of names), description (usually empty, do not rely on it).

**Settings:** `settings.primary_color`, `settings.button_text_color`, `settings.background` (a hex code only for some themes, so do not rely on it as a CSS colour), `settings.site_name`, `settings.currency_icon`, `settings.logo_url` and the others listed in Settings Variables.

**Twig:** tags `if`, `for`, `set`. Filters `upper`, `lower`, `date`, `number_format`, `default`, `length`, `first`, `last`, `join`, `slice`, `round`, `trim`, `escape`. Output is HTML escaped automatically.

**Dynamic Alpine components:**

* `raffleCountdown(seconds)`: pass `product.end_date_seconds` or `product.draw_date_seconds`. Properties `time.days`, `time.hours`, `time.minutes`, `time.seconds`, `hasEnded`, method `pad(num)`.
* `raffleProgress(percent)`: pass `product.percent_sold`. Properties `percent`, `width` (minimum 3 when above 0).
* `raffleQuickAdd(config)`: config `productId`, `price` (use `price_raw`), `maxTickets` (default 100), `hasQuestion`, `hasEnded`. Properties `tickets`, `loading`, `added`, `error`, `errorMessage`, `requiresLogin`, `canAdd`. Methods `increment()`, `decrement()`, `addToCart()`. Shows a toast and updates basket counts on success. Only render it when `not product.has_question and not product.has_ended`, inside a wrapper with `@click.prevent.stop`.

**Best practices:** keep styles in the CSS box with unique class prefixes, design mobile first for a two column phone layout, use `loading="lazy"` on images, guard optional fields with `if`, and check `cash_alt_raw > 0` before showing a cash alternative. Examples are for context only. Always follow the site owner's design directions.

***

**See also:** [Settings Variables](/advanced/twig-templating/settings-variables.md) for the site wide values available in every template, [Custom Twig Slider](/advanced/twig-templating/custom-twig-slider.md) for a custom hero section, and [Page Builder Blocks](/customise-your-site/page-builder-blocks.md) for the built-in card styles.
