diff --git a/core/urls.py b/core/urls.py
index 5ab5d86..9feaaf6 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -17,6 +17,9 @@ from twitch.feeds import GameCampaignDiscordFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameDiscordFeed
from twitch.feeds import GameFeed
+from twitch.feeds import GameLessRewardCampaignAtomFeed
+from twitch.feeds import GameLessRewardCampaignDiscordFeed
+from twitch.feeds import GameLessRewardCampaignFeed
from twitch.feeds import GameRewardCampaignAtomFeed
from twitch.feeds import GameRewardCampaignDiscordFeed
from twitch.feeds import GameRewardCampaignFeed
@@ -160,6 +163,24 @@ urlpatterns: list[URLPattern | URLResolver] = [
view=SitewideRewardCampaignDiscordFeed(),
name="sitewide_reward_feed_discord",
),
+ # /rss/rewards/no-game/ - reward campaigns with no game linked (brand + sitewide)
+ path(
+ route="rss/rewards/no-game/",
+ view=GameLessRewardCampaignFeed(),
+ name="game_less_reward_feed",
+ ),
+ # /atom/rewards/no-game/ - same as RSS but Atom
+ path(
+ route="atom/rewards/no-game/",
+ view=GameLessRewardCampaignAtomFeed(),
+ name="game_less_reward_feed_atom",
+ ),
+ # /discord/rewards/no-game/ - same as RSS but Discord timestamps
+ path(
+ route="discord/rewards/no-game/",
+ view=GameLessRewardCampaignDiscordFeed(),
+ name="game_less_reward_feed_discord",
+ ),
# Discord feeds (Atom feeds with Discord relative timestamps)
path(
route="discord/campaigns/",
diff --git a/templates/core/docs_rss.html b/templates/core/docs_rss.html
index e06b920..6d3d163 100644
--- a/templates/core/docs_rss.html
+++ b/templates/core/docs_rss.html
@@ -97,6 +97,18 @@
https://ttvdrops.lovinator.space/discord/sitewide/rewards/
+
Latest Kick drops
diff --git a/templates/twitch/game_less_rewards.html b/templates/twitch/game_less_rewards.html
new file mode 100644
index 0000000..963021d
--- /dev/null
+++ b/templates/twitch/game_less_rewards.html
@@ -0,0 +1,198 @@
+{% extends "base.html" %}
+{% load static %}
+{% block title %}
+ Brand & Site-wide Rewards
+{% endblock title %}
+{% block extra_head %}
+
+
+
+{% endblock extra_head %}
+{% block content %}
+
+
+ Brand & Site-wide Rewards
+
+
+
+ Reward campaigns not tied to a specific Twitch game. These include
+ brand-specific campaigns (e.g., Minecraft, DOORS) and
+ truly site-wide Twitch quests.
+
+
+
+
+ {% if active_campaigns %}
+
+ {% endif %}
+ {% if upcoming_campaigns %}
+
+ {% endif %}
+ {% if expired_campaigns %}
+
+ {% endif %}
+ {% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %}
+
+ 🏷️
+ No Rewards Found
+ No brand or site-wide reward campaigns are currently available.
+
+ {% endif %}
+
+{% endblock content %}
diff --git a/templates/twitch/reward_campaign_list.html b/templates/twitch/reward_campaign_list.html
index 6d8701a..5ef6590 100644
--- a/templates/twitch/reward_campaign_list.html
+++ b/templates/twitch/reward_campaign_list.html
@@ -30,17 +30,19 @@
{% if reward_campaigns %}
{% with active_campaigns=reward_campaigns|dictsortreversed:"ends_at" %}
@@ -68,6 +70,8 @@
{{ campaign.game.display_name }}
{% elif campaign.is_sitewide %}
Site-wide
+ {% else %}
+ [no game]
{% endif %}
@@ -102,6 +106,8 @@
{{ campaign.game.display_name }}
{% elif campaign.is_sitewide %}
Site-wide
+ {% else %}
+ [no game]
{% endif %}
@@ -137,6 +143,8 @@
{{ campaign.game.display_name }}
{% elif campaign.is_sitewide %}
Site-wide
+ {% else %}
+ [no game]
{% endif %}
diff --git a/templates/twitch/sitewide_rewards.html b/templates/twitch/sitewide_rewards.html
index 1f574ce..3e6b2fa 100644
--- a/templates/twitch/sitewide_rewards.html
+++ b/templates/twitch/sitewide_rewards.html
@@ -16,6 +16,18 @@
type="application/atom+xml"
title="Site-wide rewards (Discord)"
href="{% url 'core:sitewide_reward_feed_discord' %}" />
+
+
+
{% endblock extra_head %}
{% block content %}
@@ -39,6 +51,8 @@
[discord]
[explain]
+ [no game]
{% if active_campaigns %}
diff --git a/twitch/feeds.py b/twitch/feeds.py
index b1087a9..3cc4ac3 100644
--- a/twitch/feeds.py
+++ b/twitch/feeds.py
@@ -2223,3 +2223,254 @@ class SitewideRewardCampaignDiscordFeed(
def feed_url(self) -> str:
"""Return the URL to the Discord feed itself."""
return reverse("core:sitewide_reward_feed_discord")
+
+
+# MARK: /rss/rewards/no-game/
+class GameLessRewardCampaignFeed(TTVDropsBaseFeed):
+ """RSS feed for reward campaigns with no game linked (brand-specific + sitewide)."""
+
+ title: str = "TTVDrops: Game-less Rewards"
+ link: str = "/twitch/reward-campaigns/"
+ description: str = (
+ "Twitch reward campaigns without a linked game (brand-specific and sitewide)"
+ )
+
+ item_guid_is_permalink = True
+ _limit: int | None = None
+
+ def __call__(
+ self,
+ request: HttpRequest,
+ *args: str | int,
+ **kwargs: str | int,
+ ) -> HttpResponse:
+ """Override to capture limit parameter from request.
+
+ Args:
+ request: The incoming HTTP request, potentially containing a 'limit' query parameter.
+ *args: Additional positional arguments.
+ **kwargs: Additional keyword arguments.
+
+ Returns:
+ HttpResponse: The HTTP response generated by the parent Feed class.
+ """
+ if request.GET.get("limit"):
+ try:
+ self._limit = int(request.GET.get("limit", 200))
+ except ValueError, TypeError:
+ self._limit = None
+ return super().__call__(request, *args, **kwargs)
+
+ def items(self) -> list[RewardCampaign]:
+ """Return reward campaigns with no game linked, ordered by newest."""
+ limit: int = self._limit if self._limit is not None else 200
+ return list(
+ RewardCampaign.objects
+ .filter(game__isnull=True)
+ .select_related("game")
+ .order_by("-starts_at")[:limit],
+ )
+
+ def item_title(self, item: RewardCampaign) -> SafeText:
+ """Return the reward campaign name as the item title."""
+ if item.brand:
+ return SafeText(f"{item.brand}: {item.name}")
+ return SafeText(item.name)
+
+ def item_description(self, item: RewardCampaign) -> SafeText:
+ """Return a description of the reward campaign."""
+ parts: list = []
+
+ if item.summary:
+ parts.append(format_html("{}
", item.summary))
+
+ if item.starts_at or item.ends_at:
+ start_part = (
+ format_html(
+ "Starts: {} ({})",
+ item.starts_at.strftime("%Y-%m-%d %H:%M %Z"),
+ naturaltime(item.starts_at),
+ )
+ if item.starts_at
+ else ""
+ )
+ end_part = (
+ format_html(
+ "Ends: {} ({})",
+ item.ends_at.strftime("%Y-%m-%d %H:%M %Z"),
+ naturaltime(item.ends_at),
+ )
+ if item.ends_at
+ else ""
+ )
+ if start_part and end_part:
+ parts.append(format_html("{} {}
", start_part, end_part))
+ elif start_part:
+ parts.append(format_html("{}
", start_part))
+ elif end_part:
+ parts.append(format_html("{}
", end_part))
+
+ if item.brand:
+ parts.append(
+ format_html("Brand: {}
", item.brand),
+ )
+
+ if item.is_sitewide:
+ parts.append(
+ SafeText("Site-wide reward campaign
"),
+ )
+
+ if item.about_url:
+ parts.append(
+ format_html('Learn more
', item.about_url),
+ )
+
+ if item.external_url:
+ parts.append(
+ format_html(
+ 'Redeem reward
',
+ item.external_url,
+ ),
+ )
+
+ return SafeText("".join(str(p) for p in parts))
+
+ def item_link(self, item: RewardCampaign) -> str:
+ """Return the link to the reward campaign detail."""
+ return reverse("twitch:reward_campaign_detail", args=[item.twitch_id])
+
+ def item_pubdate(self, item: RewardCampaign) -> datetime.datetime:
+ """Returns the publication date to the feed item."""
+ if item.starts_at:
+ return item.starts_at
+ if item.added_at:
+ return item.added_at
+ return timezone.now()
+
+ def item_updateddate(self, item: RewardCampaign) -> datetime.datetime:
+ """Returns the reward campaign's last update time."""
+ return item.updated_at
+
+ def item_categories(self, item: RewardCampaign) -> tuple[str, ...]:
+ """Returns categories for the feed item."""
+ categories: list[str] = ["twitch", "rewards", "quests"]
+ if item.brand:
+ categories.append(item.brand)
+ if item.is_sitewide:
+ categories.append("sitewide")
+ return tuple(categories)
+
+ def item_guid(self, item: RewardCampaign) -> str:
+ """Return a unique identifier for each reward campaign."""
+ return self._absolute_url(
+ reverse("twitch:reward_campaign_detail", args=[item.twitch_id]),
+ )
+
+ def item_author_name(self, item: RewardCampaign) -> str:
+ """Return the author name for the reward campaign."""
+ return item.brand or "Twitch"
+
+ def item_enclosures(self, item: RewardCampaign) -> list[feedgenerator.Enclosure]:
+ """Return a list of enclosures for the reward campaign, if available."""
+ image_url: str = getattr(item, "image_best_url", "")
+ if image_url:
+ try:
+ size: int | None = getattr(item, "image_size_bytes", None)
+ length: int = int(size) if size is not None else 0
+ except TypeError, ValueError:
+ length = 0
+
+ if not length:
+ return []
+
+ mime: str = getattr(item, "image_mime_type", "")
+ mime_type: str = mime or "image/jpeg"
+
+ return [
+ feedgenerator.Enclosure(
+ self._absolute_url(image_url),
+ str(length),
+ mime_type,
+ ),
+ ]
+ return []
+
+ def feed_url(self) -> str:
+ """Return the URL to the RSS feed itself."""
+ return reverse("core:game_less_reward_feed")
+
+
+class GameLessRewardCampaignAtomFeed(TTVDropsAtomBaseFeed, GameLessRewardCampaignFeed):
+ """Atom feed for reward campaigns with no game linked."""
+
+ def feed_url(self) -> str:
+ """Return the URL to the Atom feed itself."""
+ return reverse("core:game_less_reward_feed_atom")
+
+
+class GameLessRewardCampaignDiscordFeed(
+ TTVDropsAtomBaseFeed, GameLessRewardCampaignFeed
+):
+ """Discord feed for reward campaigns with no game linked, with Discord timestamps."""
+
+ def item_description(self, item: RewardCampaign) -> SafeText:
+ """Return a description with Discord timestamps."""
+ parts: list = []
+
+ if item.summary:
+ parts.append(format_html("{}
", item.summary))
+
+ if item.starts_at or item.ends_at:
+ start_part = (
+ format_html(
+ "Starts: {} ({})",
+ item.starts_at.strftime("%Y-%m-%d %H:%M %Z"),
+ discord_timestamp(item.starts_at),
+ )
+ if item.starts_at
+ else ""
+ )
+ end_part = (
+ format_html(
+ "Ends: {} ({})",
+ item.ends_at.strftime("%Y-%m-%d %H:%M %Z"),
+ discord_timestamp(item.ends_at),
+ )
+ if item.ends_at
+ else ""
+ )
+ if start_part and end_part:
+ parts.append(format_html("{} {}
", start_part, end_part))
+ elif start_part:
+ parts.append(format_html("{}
", start_part))
+ elif end_part:
+ parts.append(format_html("{}
", end_part))
+
+ if item.brand:
+ parts.append(
+ format_html("Brand: {}
", item.brand),
+ )
+
+ if item.is_sitewide:
+ parts.append(
+ SafeText("Site-wide reward campaign
"),
+ )
+
+ if item.about_url:
+ parts.append(
+ format_html('Learn more
', item.about_url),
+ )
+
+ if item.external_url:
+ parts.append(
+ format_html(
+ 'Redeem reward
',
+ item.external_url,
+ ),
+ )
+
+ return SafeText("".join(str(p) for p in parts))
+
+ def feed_url(self) -> str:
+ """Return the URL to the Discord feed itself."""
+ return reverse("core:game_less_reward_feed_discord")
diff --git a/twitch/urls.py b/twitch/urls.py
index 8fd6147..b114c9e 100644
--- a/twitch/urls.py
+++ b/twitch/urls.py
@@ -71,6 +71,12 @@ urlpatterns: list[URLPattern | URLResolver] = [
views.sitewide_rewards_view,
name="sitewide_rewards",
),
+ # /twitch/rewards/no-game/
+ path(
+ "rewards/no-game/",
+ views.game_less_rewards_view,
+ name="game_less_rewards",
+ ),
# /twitch/export/campaigns/csv/
path(
"export/campaigns/csv/",
diff --git a/twitch/views.py b/twitch/views.py
index 5af0eea..910a62c 100644
--- a/twitch/views.py
+++ b/twitch/views.py
@@ -1728,3 +1728,84 @@ def sitewide_rewards_view(request: HttpRequest) -> HttpResponse:
}
return render(request, "twitch/sitewide_rewards.html", context)
+
+
+# MARK: /rewards/no-game/
+def game_less_rewards_view(request: HttpRequest) -> HttpResponse:
+ """View to display all reward campaigns with no game linked.
+
+ Includes both site-wide (is_sitewide=True) and brand-specific campaigns
+ that don't have a Twitch game associated with them.
+
+ Args:
+ request: The HTTP request.
+
+ Returns:
+ HttpResponse: The rendered game-less rewards page.
+ """
+ now: datetime.datetime = timezone.now()
+
+ # Fetch all reward campaigns without a game
+ campaigns: QuerySet[RewardCampaign] = (
+ RewardCampaign.objects
+ .filter(game__isnull=True)
+ .select_related("game")
+ .order_by("-starts_at")
+ )
+
+ # Split into active, upcoming, expired
+ active_campaigns: list[RewardCampaign] = []
+ upcoming_campaigns: list[RewardCampaign] = []
+ expired_campaigns: list[RewardCampaign] = []
+
+ for campaign in campaigns:
+ if campaign.starts_at is None or campaign.ends_at is None:
+ continue
+ if campaign.starts_at <= now <= campaign.ends_at:
+ active_campaigns.append(campaign)
+ elif campaign.starts_at > now:
+ upcoming_campaigns.append(campaign)
+ elif campaign.ends_at < now:
+ expired_campaigns.append(campaign)
+
+ title: str = "Brand & Site-wide Rewards"
+ description: str = (
+ "Twitch reward campaigns without a linked game (brand-specific and site-wide)."
+ )
+
+ url: str = build_absolute_uri(reverse("twitch:game_less_rewards"))
+
+ # CollectionPage schema
+ collection_schema: dict[str, str] = {
+ "@context": "https://schema.org",
+ "@type": "CollectionPage",
+ "name": title,
+ "description": description,
+ "url": url,
+ }
+
+ # Breadcrumb schema
+ breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
+ {"name": "Home", "url": build_absolute_uri("/")},
+ {"name": "Reward Campaigns", "url": build_absolute_uri("/reward-campaigns/")},
+ {"name": title, "url": url},
+ ])
+
+ seo_context: dict[str, Any] = _build_seo_context(
+ page_title=title,
+ page_description=description,
+ seo_meta={
+ "page_url": url,
+ "schema_data": collection_schema,
+ "breadcrumb_schema": breadcrumb_schema,
+ },
+ )
+ context: dict[str, Any] = {
+ "active_campaigns": active_campaigns,
+ "upcoming_campaigns": upcoming_campaigns,
+ "expired_campaigns": expired_campaigns,
+ "now": now,
+ **seo_context,
+ }
+
+ return render(request, "twitch/game_less_rewards.html", context)