Skip to content

Technical Execution

Created: 2026-03-09 Status: READY FOR EXECUTION Total: 9 Tasks Target: AI-agent-ready with exact file paths, code changes, and acceptance criteria

Stack: Astro 5.18 (static SSG), Tailwind CSS v4.2, Alpine.js 3.14.8 (CDN) Pages: 10 EN (src/pages/) + 9 AR (src/pages/ar/) + 1 bilingual 404 Site: https://urwhats.com | App: https://app.urwhats.com


Install MDX support, create content collections, blog listing pages, post template, navigation entry, sitemap integration, and Article schema.

File: package.json

Terminal window
npm install @astrojs/mdx

This adds @astrojs/mdx to dependencies in package.json.

File: astro.config.mjs

Before:

import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
output: 'static',
compressHTML: true,
site: 'https://urwhats.com',
trailingSlash: 'never',

After:

import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import mdx from '@astrojs/mdx';
export default defineConfig({
output: 'static',
compressHTML: true,
site: 'https://urwhats.com',
trailingSlash: 'never',
integrations: [mdx()],

No other changes to astro.config.mjs are needed. The rest of the file (devToolbar, prefetch, build, image, vite) stays identical.

File: src/content/config.ts (NEW)

import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string().max(160),
date: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
author: z.string().default('urWhats Team'),
category: z.enum([
'guides',
'product-updates',
'whatsapp-business',
'case-studies',
'industry',
'tips',
]),
tags: z.array(z.string()).default([]),
lang: z.enum(['en', 'ar']),
image: z.object({
src: z.string(),
alt: z.string(),
}).optional(),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };

1.4 Create content directories and placeholder post

Section titled “1.4 Create content directories and placeholder post”

Directories: src/content/blog/en/ and src/content/blog/ar/ (NEW)

File: src/content/blog/en/getting-started-whatsapp-business-api.mdx (NEW - placeholder)

---
title: "Getting Started with WhatsApp Business API in Saudi Arabia"
description: "A complete guide to setting up WhatsApp Business API for your Saudi business. Learn about Meta requirements, pricing, and best practices."
date: 2026-03-09
author: "urWhats Team"
category: "guides"
tags: ["whatsapp-api", "getting-started", "saudi-arabia"]
lang: "en"
image:
src: "/assets/images/photos/dashboard-en.webp"
alt: "urWhats Dashboard showing WhatsApp Business API setup"
---
# Getting Started with WhatsApp Business API in Saudi Arabia
This is a placeholder post. Replace with real content before publishing.

File: src/content/blog/ar/getting-started-whatsapp-business-api.mdx (NEW - placeholder)

---
title: "دليل البدء مع واجهة واتساب للأعمال API في السعودية"
description: "دليل شامل لإعداد واتساب للأعمال API لشركتك السعودية. تعرف على متطلبات Meta والأسعار وأفضل الممارسات."
date: 2026-03-09
author: "فريق urWhats"
category: "guides"
tags: ["واتساب-api", "البدء", "السعودية"]
lang: "ar"
image:
src: "/assets/images/photos/dashboard-ar.webp"
alt: "لوحة تحكم urWhats لإعداد واتساب للأعمال"
---
# دليل البدء مع واجهة واتساب للأعمال API في السعودية
هذا منشور مؤقت. استبدله بمحتوى حقيقي قبل النشر.

File: src/pages/blog/index.astro (NEW)

---
import Layout from '../../layouts/Layout.astro';
import { useLanguagePage, getLink } from '../../config/page.config';
import Container from '../../components/ui/Container.astro';
import Section from '../../components/ui/Section.astro';
import SectionHeader from '../../components/ui/SectionHeader.astro';
import CTASection from '../../components/ui/CTASection.astro';
import { getCollection } from 'astro:content';
const { lang, t } = useLanguagePage(Astro);
const allPosts = await getCollection('blog', ({ data }) => {
return data.lang === 'en' && !data.draft;
});
// Sort by date descending (newest first)
const posts = allPosts.sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
);
// Group by category for optional filtering
const categories = [...new Set(posts.map(p => p.data.category))];
---
<Layout lang={lang} title="Blog | urWhats">
<Section bg="white" class="pt-28 sm:pt-36">
<Container>
<SectionHeader
title="Blog"
subtitle="Insights, guides, and updates on WhatsApp Business API"
/>
{posts.length === 0 ? (
<div class="text-center py-16">
<p class="text-neutral-500 text-lg">No posts yet. Check back soon.</p>
</div>
) : (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post) => (
<article class="bg-white rounded-2xl border border-neutral-200 overflow-hidden hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
{post.data.image && (
<a href={`/blog/${post.slug}`}>
<img
src={post.data.image.src}
alt={post.data.image.alt}
class="w-full h-48 object-cover"
width="400"
height="192"
loading="lazy"
/>
</a>
)}
<div class="p-6">
<div class="flex items-center gap-3 mb-3">
<span class="text-xs font-semibold uppercase tracking-wider text-primary-600 bg-primary-50 px-2.5 py-1 rounded-full">
{post.data.category.replace('-', ' ')}
</span>
<time
datetime={post.data.date.toISOString()}
class="text-xs text-neutral-400"
>
{post.data.date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
</div>
<h2 class="text-lg font-semibold text-neutral-900 mb-2 line-clamp-2">
<a href={`/blog/${post.slug}`} class="hover:text-primary-600 transition-colors">
{post.data.title}
</a>
</h2>
<p class="text-sm text-neutral-500 line-clamp-3 mb-4">
{post.data.description}
</p>
<a
href={`/blog/${post.slug}`}
class="inline-flex items-center gap-1 text-sm font-semibold text-primary-600 hover:text-primary-700 transition-colors"
>
Read more
<svg class="w-4 h-4 rtl:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</article>
))}
</div>
)}
</Container>
</Section>
<CTASection
title="Ready to grow your business with WhatsApp?"
subtitle="Join hundreds of Saudi businesses using urWhats."
primaryText="Get Started Free"
primaryHref={getLink(lang, 'register')}
secondaryText="View Plans"
secondaryHref={getLink(lang, 'prices')}
/>
</Layout>

File: src/pages/ar/blog/index.astro (NEW)

---
import Layout from '../../../layouts/Layout.astro';
import { useLanguagePage, getLink } from '../../../config/page.config';
import Container from '../../../components/ui/Container.astro';
import Section from '../../../components/ui/Section.astro';
import SectionHeader from '../../../components/ui/SectionHeader.astro';
import CTASection from '../../../components/ui/CTASection.astro';
import { getCollection } from 'astro:content';
const { lang, t } = useLanguagePage(Astro);
const allPosts = await getCollection('blog', ({ data }) => {
return data.lang === 'ar' && !data.draft;
});
const posts = allPosts.sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
);
---
<Layout lang={lang} title="المدونة | urWhats">
<Section bg="white" class="pt-28 sm:pt-36">
<Container>
<SectionHeader
title="المدونة"
subtitle="مقالات وأدلة وتحديثات حول واتساب للأعمال API"
/>
{posts.length === 0 ? (
<div class="text-center py-16">
<p class="text-neutral-500 text-lg">لا توجد مقالات بعد. تابعنا قريباً.</p>
</div>
) : (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post) => (
<article class="bg-white rounded-2xl border border-neutral-200 overflow-hidden hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
{post.data.image && (
<a href={`/ar/blog/${post.slug}`}>
<img
src={post.data.image.src}
alt={post.data.image.alt}
class="w-full h-48 object-cover"
width="400"
height="192"
loading="lazy"
/>
</a>
)}
<div class="p-6">
<div class="flex items-center gap-3 mb-3">
<span class="text-xs font-semibold uppercase tracking-wider text-primary-600 bg-primary-50 px-2.5 py-1 rounded-full">
{post.data.category.replace('-', ' ')}
</span>
<time
datetime={post.data.date.toISOString()}
class="text-xs text-neutral-400"
>
{post.data.date.toLocaleDateString('ar-SA', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
</div>
<h2 class="text-lg font-semibold text-neutral-900 mb-2 line-clamp-2">
<a href={`/ar/blog/${post.slug}`} class="hover:text-primary-600 transition-colors">
{post.data.title}
</a>
</h2>
<p class="text-sm text-neutral-500 line-clamp-3 mb-4">
{post.data.description}
</p>
<a
href={`/ar/blog/${post.slug}`}
class="inline-flex items-center gap-1 text-sm font-semibold text-primary-600 hover:text-primary-700 transition-colors"
>
اقرأ المزيد
<svg class="w-4 h-4 rtl:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</article>
))}
</div>
)}
</Container>
</Section>
<CTASection
title="مستعد لتنمية أعمالك عبر واتساب؟"
subtitle="انضم لمئات الشركات السعودية التي تستخدم urWhats."
primaryText="ابدأ مجاناً"
primaryHref={getLink(lang, 'register')}
secondaryText="عرض الباقات"
secondaryHref={getLink(lang, 'prices')}
/>
</Layout>

File: src/pages/blog/[...slug].astro (NEW)

---
import Layout from '../../layouts/Layout.astro';
import { useLanguagePage, getLink } from '../../config/page.config';
import Container from '../../components/ui/Container.astro';
import CTASection from '../../components/ui/CTASection.astro';
import { getCollection } from 'astro:content';
const { lang, t } = useLanguagePage(Astro);
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => {
return data.lang === 'en' && !data.draft;
});
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
const formattedDate = post.data.date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
// Find the Arabic mirror post for hreflang (same slug convention)
const arPosts = await getCollection('blog', ({ data }) => data.lang === 'ar');
const arMirror = arPosts.find(p => p.slug === post.slug);
---
<Layout lang={lang} title={`${post.data.title} | urWhats Blog`}>
<article class="pt-28 sm:pt-36 pb-16">
<Container narrow>
<!-- Breadcrumb -->
<nav class="mb-8 text-sm text-neutral-400" aria-label="Breadcrumb">
<ol class="flex items-center gap-2">
<li><a href="/" class="hover:text-primary-600 transition-colors">Home</a></li>
<li>/</li>
<li><a href="/blog" class="hover:text-primary-600 transition-colors">Blog</a></li>
<li>/</li>
<li class="text-neutral-600 truncate max-w-[200px]">{post.data.title}</li>
</ol>
</nav>
<!-- Article Header -->
<header class="mb-10">
<div class="flex items-center gap-3 mb-4">
<span class="text-xs font-semibold uppercase tracking-wider text-primary-600 bg-primary-50 px-2.5 py-1 rounded-full">
{post.data.category.replace('-', ' ')}
</span>
<time datetime={post.data.date.toISOString()} class="text-sm text-neutral-400">
{formattedDate}
</time>
</div>
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-neutral-900 mb-4">
{post.data.title}
</h1>
<p class="text-lg text-neutral-500">{post.data.description}</p>
{post.data.author && (
<p class="mt-4 text-sm text-neutral-400">
By <span class="text-neutral-600 font-medium">{post.data.author}</span>
</p>
)}
</header>
{/* Hero image */}
{post.data.image && (
<div class="rounded-2xl overflow-hidden mb-10 border border-neutral-200">
<img
src={post.data.image.src}
alt={post.data.image.alt}
class="w-full"
width="800"
height="400"
loading="eager"
/>
</div>
)}
{/* Article body */}
<div class="prose prose-lg prose-neutral max-w-none
prose-headings:font-bold prose-headings:tracking-tight
prose-a:text-primary-600 prose-a:no-underline hover:prose-a:underline
prose-img:rounded-xl prose-img:border prose-img:border-neutral-200">
<Content />
</div>
{/* Tags */}
{post.data.tags.length > 0 && (
<div class="mt-10 pt-6 border-t border-neutral-200">
<div class="flex flex-wrap gap-2">
{post.data.tags.map((tag: string) => (
<span class="text-xs font-medium text-neutral-500 bg-neutral-100 px-3 py-1.5 rounded-full">
#{tag}
</span>
))}
</div>
</div>
)}
</Container>
</article>
<CTASection
title="Ready to get started?"
subtitle="Join hundreds of Saudi businesses using urWhats."
primaryText="Get Started Free"
primaryHref={getLink(lang, 'register')}
secondaryText="View Plans"
secondaryHref={getLink(lang, 'prices')}
/>
</Layout>

File: src/pages/ar/blog/[...slug].astro (NEW)

---
import Layout from '../../../layouts/Layout.astro';
import { useLanguagePage, getLink } from '../../../config/page.config';
import Container from '../../../components/ui/Container.astro';
import CTASection from '../../../components/ui/CTASection.astro';
import { getCollection } from 'astro:content';
const { lang, t } = useLanguagePage(Astro);
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => {
return data.lang === 'ar' && !data.draft;
});
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
const formattedDate = post.data.date.toLocaleDateString('ar-SA', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
---
<Layout lang={lang} title={`${post.data.title} | مدونة urWhats`}>
<article class="pt-28 sm:pt-36 pb-16">
<Container narrow>
<!-- Breadcrumb -->
<nav class="mb-8 text-sm text-neutral-400" aria-label="مسار التنقل">
<ol class="flex items-center gap-2">
<li><a href="/ar" class="hover:text-primary-600 transition-colors">الرئيسية</a></li>
<li>/</li>
<li><a href="/ar/blog" class="hover:text-primary-600 transition-colors">المدونة</a></li>
<li>/</li>
<li class="text-neutral-600 truncate max-w-[200px]">{post.data.title}</li>
</ol>
</nav>
<header class="mb-10">
<div class="flex items-center gap-3 mb-4">
<span class="text-xs font-semibold uppercase tracking-wider text-primary-600 bg-primary-50 px-2.5 py-1 rounded-full">
{post.data.category.replace('-', ' ')}
</span>
<time datetime={post.data.date.toISOString()} class="text-sm text-neutral-400">
{formattedDate}
</time>
</div>
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-neutral-900 mb-4">
{post.data.title}
</h1>
<p class="text-lg text-neutral-500">{post.data.description}</p>
{post.data.author && (
<p class="mt-4 text-sm text-neutral-400">
بقلم <span class="text-neutral-600 font-medium">{post.data.author}</span>
</p>
)}
</header>
{post.data.image && (
<div class="rounded-2xl overflow-hidden mb-10 border border-neutral-200">
<img
src={post.data.image.src}
alt={post.data.image.alt}
class="w-full"
width="800"
height="400"
loading="eager"
/>
</div>
)}
<div class="prose prose-lg prose-neutral max-w-none
prose-headings:font-bold prose-headings:tracking-tight
prose-a:text-primary-600 prose-a:no-underline hover:prose-a:underline
prose-img:rounded-xl prose-img:border prose-img:border-neutral-200">
<Content />
</div>
{post.data.tags.length > 0 && (
<div class="mt-10 pt-6 border-t border-neutral-200">
<div class="flex flex-wrap gap-2">
{post.data.tags.map((tag: string) => (
<span class="text-xs font-medium text-neutral-500 bg-neutral-100 px-3 py-1.5 rounded-full">
#{tag}
</span>
))}
</div>
</div>
)}
</Container>
</article>
<CTASection
title="مستعد للبدء؟"
subtitle="انضم لمئات الشركات السعودية التي تستخدم urWhats."
primaryText="ابدأ مجاناً"
primaryHref={getLink(lang, 'register')}
secondaryText="عرض الباقات"
secondaryHref={getLink(lang, 'prices')}
/>
</Layout>

File: src/components/Navigation.astro

Find the navLinks array (line 24-32) and add the blog entry:

Before:

const navLinks = [
{ label: t('nav.home'), href: getLink(lang, ''), match: '' },
{ label: t('nav.features'), href: getLink(lang, 'features'), match: 'features' },
{ label: t('nav.solutions'), href: getLink(lang, 'solutions'), match: 'solutions' },
{ label: t('nav.pricing'), href: getLink(lang, 'prices'), match: 'prices' },
{ label: t('nav.integrations'), href: getLink(lang, 'integrations'), match: 'integrations' },
{ label: t('nav.faqs'), href: getLink(lang, 'faqs'), match: 'faqs' },
{ label: t('nav.contact'), href: getLink(lang, 'contact'), match: 'contact' },
];

After:

const navLinks = [
{ label: t('nav.home'), href: getLink(lang, ''), match: '' },
{ label: t('nav.features'), href: getLink(lang, 'features'), match: 'features' },
{ label: t('nav.solutions'), href: getLink(lang, 'solutions'), match: 'solutions' },
{ label: t('nav.pricing'), href: getLink(lang, 'prices'), match: 'prices' },
{ label: t('nav.integrations'), href: getLink(lang, 'integrations'), match: 'integrations' },
{ label: t('nav.blog'), href: getLink(lang, 'blog'), match: 'blog' },
{ label: t('nav.faqs'), href: getLink(lang, 'faqs'), match: 'faqs' },
{ label: t('nav.contact'), href: getLink(lang, 'contact'), match: 'contact' },
];

File: public/assets/i18n/en.json

Add to the "navigation" object (alongside existing "nav.home", "nav.features", etc.):

"nav.blog": "Blog",

File: public/assets/i18n/ar.json

"nav.blog": "المدونة",

Also add meta keys for the blog listing page:

en.json - add inside the "meta" object:

"blog": {
"title": "Blog | urWhats - WhatsApp Business API Insights & Guides",
"description": "Expert guides, product updates, and insights on WhatsApp Business API, chatbots, and business automation in Saudi Arabia.",
"keywords": "WhatsApp Business blog, WhatsApp API guides, business messaging tips, chatbot tutorials, Saudi Arabia WhatsApp"
}

ar.json - add inside the "meta" object:

"blog": {
"title": "المدونة | urWhats - مقالات وأدلة واتساب للأعمال",
"description": "أدلة متخصصة وتحديثات ومقالات حول واتساب للأعمال API وروبوتات المحادثة وأتمتة الأعمال في السعودية.",
"keywords": "مدونة واتساب للأعمال, أدلة واتساب API, نصائح المراسلة, دروس روبوت المحادثة, واتساب السعودية"
}

The blog pages will be auto-discovered by Astro since they are static pages generated via getStaticPaths(). If you have a custom sitemap at src/pages/sitemap-index.xml.ts, add blog entries there.

If not using a custom sitemap generator, the static pages at /blog/* and /ar/blog/* will be included automatically when using @astrojs/sitemap.

For the custom sitemap, add this pattern for dynamically discovering blog posts:

File: src/pages/sitemap-index.xml.ts (if exists, modify; otherwise skip)

Add after existing page entries:

import { getCollection } from 'astro:content';
// Inside the function that generates sitemap entries:
const enPosts = await getCollection('blog', ({ data }) => data.lang === 'en' && !data.draft);
const arPosts = await getCollection('blog', ({ data }) => data.lang === 'ar' && !data.draft);
// Add to entries array:
for (const post of enPosts) {
entries.push({
url: `${site}/blog/${post.slug}`,
lastmod: (post.data.updatedDate ?? post.data.date).toISOString(),
changefreq: 'monthly',
priority: 0.7,
alternates: {
en: `${site}/blog/${post.slug}`,
ar: `${site}/ar/blog/${post.slug}`,
},
});
}
for (const post of arPosts) {
entries.push({
url: `${site}/ar/blog/${post.slug}`,
lastmod: (post.data.updatedDate ?? post.data.date).toISOString(),
changefreq: 'monthly',
priority: 0.7,
alternates: {
en: `${site}/blog/${post.slug}`,
ar: `${site}/ar/blog/${post.slug}`,
},
});
}
  • npm install succeeds with @astrojs/mdx added
  • npm run build succeeds with 0 errors
  • /blog renders the English blog listing page
  • /ar/blog renders the Arabic blog listing page
  • /blog/getting-started-whatsapp-business-api renders the placeholder post
  • /ar/blog/getting-started-whatsapp-business-api renders the Arabic placeholder post
  • “Blog” / “المدونة” appears in navigation on all pages
  • Blog listing shows correct date formatting per language (en-US / ar-SA)
  • Content collection schema validates frontmatter (try invalid category to confirm)
  • Draft posts (draft: true) are excluded from listings and builds
  • Blog post pages include breadcrumb navigation
  • Blog pages include CTA section at bottom

Replace all “Start Free Trial” with “Get Started Free” (EN) and all trial references with “ابدأ مجاناً” (AR). Update trial badge text.

File: public/assets/i18n/en.json

LineKeyBeforeAfter
140home.hero.buttons.subscribe"Start Free Trial""Get Started Free"
136home.pricing.title (prefix)"Start your free trial""Start growing with WhatsApp"

File: public/assets/i18n/ar.json

Update the matching keys. The Arabic CTA equivalent of “Get Started Free” is "ابدأ مجاناً". Ensure all instances of "ابدأ تجربة مجانية" and "ابدأ تجربتك المجانية" are changed to "ابدأ مجاناً".

File: src/config/static-plans.config.ts

Before (line 179):

startTrial: { en: 'Start Free Trial', ar: 'ابدأ تجربة مجانية' },

After:

startTrial: { en: 'Get Started Free', ar: 'ابدأ مجاناً' },

Before (line 188):

trialBadge: { en: '7-day free trial', ar: 'تجربة مجانية ٧ أيام' },

After:

trialBadge: { en: '250 free messages \u2022 No credit card', ar: '٢٥٠ رسالة مجانية \u2022 بدون بطاقة ائتمان' },

Each file below has hardcoded “Start Free Trial” strings that must be replaced with “Get Started Free” (EN) and “ابدأ مجاناً” (AR):

File: src/pages/features.astro

Line 68 — change:

{lang === 'ar' ? 'ابدأ تجربتك المجانية' : 'Start Free Trial'}

to:

{lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

Line 225 — change primaryText:

primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Start Free Trial'}

to:

primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

File: src/pages/ar/features.astro — same changes at lines 68 and 225.

File: src/pages/solutions.astro

Line 205 — change:

primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Start Free Trial'}

to:

primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

File: src/pages/ar/solutions.astro — line 204, same change.

File: src/pages/integrations.astro

Line 172 — change:

{lang === 'ar' ? 'ابدأ مجانًا' : 'Start Free Trial'}

to:

{lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

Line 182 — change primaryText:

primaryText={lang === 'ar' ? 'ابدأ مجانًا' : 'Start Free Trial'}

to:

primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

File: src/pages/ar/integrations.astro — lines 171, 181, same changes.

File: src/pages/prices.astro

Line 28-29 — change subtitle and primaryText:

subtitle={lang === 'ar' ? 'ابدأ تجربتك المجانية لمدة 14 يومًا بدون بطاقة ائتمان' : 'Start your 14-day free trial. No credit card required.'}
primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Start Free Trial'}

to:

subtitle={lang === 'ar' ? '٢٥٠ رسالة مجانية للبدء. بدون بطاقة ائتمان.' : '250 free messages to get started. No credit card required.'}
primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

File: src/pages/ar/prices.astro — lines 27-28, same changes.

File: src/pages/faqs.astro

Line 52 — change:

secondaryText={lang === 'ar' ? 'ابدأ مجانًا' : 'Start Free Trial'}

to:

secondaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

File: src/pages/ar/faqs.astro — line 51, same change.

File: src/components/SEO.astro

Lines 289-294 — update the free trial FAQ:

Before:

"name": lang === 'ar' ? "هل هناك فترة تجريبية مجانية؟" : "Is there a free trial available?",
"acceptedAnswer": {
"@type": "Answer",
"text": lang === 'ar'
? "نعم، نقدم فترة تجريبية مجانية حتى تتمكن من استكشاف منصتنا قبل الاشتراك"
: "Yes, we offer a free trial so you can explore our platform before committing."
}

After:

"name": lang === 'ar' ? "هل يمكنني تجربة المنصة مجاناً؟" : "Can I try the platform for free?",
"acceptedAnswer": {
"@type": "Answer",
"text": lang === 'ar'
? "نعم، نقدم ٢٥٠ رسالة مجانية للبدء بدون الحاجة لبطاقة ائتمان."
: "Yes, you get 250 free messages to get started — no credit card required."
}

File: public/assets/i18n/en.json

Line 5 (home meta description) — replace "Start your free trial." with "Get started free with 250 messages.":

Before:

"description": "Official Meta Technical Provider in Saudi Arabia. WhatsApp Business API platform for bulk messaging, AI chatbots, and CRM integration. SAR pricing, Arabic-first support, Salla integration. Start your free trial.",

After:

"description": "Official Meta Technical Provider in Saudi Arabia. WhatsApp Business API platform for bulk messaging, AI chatbots, and CRM integration. SAR pricing, Arabic-first support, Salla integration. Get started free with 250 messages.",

Line 60 (features meta description) — same replacement.

Line 390 (pricing meta description):

Before:

"description": "Transparent SAR pricing with no hidden fees. Every plan includes the official Meta WhatsApp API, 7-day free trial, and dedicated support."

After:

"description": "Transparent SAR pricing with no hidden fees. Every plan includes the official Meta WhatsApp API, 250 free messages, and dedicated support."
FileChanges
public/assets/i18n/en.json~5 key value updates
public/assets/i18n/ar.json~5 matching key value updates
src/config/static-plans.config.tsstartTrial label + trialBadge text
src/pages/features.astro2 CTA strings
src/pages/ar/features.astro2 CTA strings
src/pages/solutions.astro1 CTA string
src/pages/ar/solutions.astro1 CTA string
src/pages/integrations.astro2 CTA strings
src/pages/ar/integrations.astro2 CTA strings
src/pages/prices.astro2 CTA strings (subtitle + primaryText)
src/pages/ar/prices.astro2 CTA strings
src/pages/faqs.astro1 CTA string
src/pages/ar/faqs.astro1 CTA string
src/components/SEO.astro1 FAQ schema Q&A

Total: ~25 changes across 14 files.

  • Zero instances of “Start Free Trial” remain in any .astro or .json file
  • Zero instances of “7-day free trial” remain anywhere
  • All EN CTA buttons read “Get Started Free”
  • All AR CTA buttons read “ابدأ مجاناً”
  • Trial badge on pricing page reads “250 free messages - No credit card” / “٢٥٠ رسالة مجانية - بدون بطاقة ائتمان”
  • Meta descriptions updated to reference “250 free messages” instead of “free trial”
  • npm run build succeeds with 0 errors
  • Verify both EN and AR pricing pages display updated badge text

Add a Free plan (0 SAR) to the pricing grid alongside the existing 4 plans.

File: src/config/static-plans.config.ts

Add as the first element in the plans array (before Starter):

// ── Free ──
{
id: 'free',
nameEn: 'Free',
nameAr: 'مجانية',
descriptionEn: 'Try urWhats with no commitment',
descriptionAr: 'جرّب urWhats بدون أي التزام',
pricing: {
monthly: { sar: 0, usd: 0 },
yearly: { sar: 0, usd: 0 },
},
isRecommended: false,
isContactUs: false,
ctaType: 'trial',
features: [
{ key: 'whatsapp_numbers', en: '1 WhatsApp Number', ar: 'رقم واتساب واحد', included: true },
{ key: 'users', en: '1 User', ar: 'مستخدم واحد', included: true },
{ key: 'contacts', en: '250 Contacts', ar: '٢٥٠ جهة اتصال', included: true },
{ key: 'messages', en: '250 Messages / month', ar: '٢٥٠ رسالة / شهرياً', included: true },
{ key: 'chatbot', en: 'Basic Chatbot', ar: 'روبوت محادثة أساسي', included: true },
{ key: 'support', en: 'Community Support', ar: 'دعم مجتمعي', included: true },
],
},

File: src/components/DynamicPlans.astro

Add to the planCtaUrls object (line 29-34):

Before:

const planCtaUrls: Record<string, string> = {
starter: `${registerBase}?plan=3`,
growth: `${registerBase}?plan=1`,
pro: `${registerBase}?plan=2`,
enterprise: getLink(lang, 'contact'),
};

After:

const planCtaUrls: Record<string, string> = {
free: registerBase,
starter: `${registerBase}?plan=3`,
growth: `${registerBase}?plan=1`,
pro: `${registerBase}?plan=2`,
enterprise: getLink(lang, 'contact'),
};

3.3 Update pricing grid layout for 5 columns

Section titled “3.3 Update pricing grid layout for 5 columns”

File: src/components/DynamicPlans.astro

The current grid is grid-cols-1 md:grid-cols-2 xl:grid-cols-4. With 5 plans, change to allow 5 columns on wide screens or keep 4 columns with wrapping.

Option A: 5-column layout (recommended for desktop)

Change line 128:

Before:

<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 items-stretch">

After:

<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-5 items-stretch">

Also reduce card padding slightly for the 5-col layout. Change the card container (line 130):

Before:

<div class={`rounded-2xl p-6 sm:p-8 border hover:border-primary-300 ...`}>

After:

<div class={`rounded-2xl p-5 sm:p-6 border hover:border-primary-300 ...`}>

The existing Alpine.js getPrice() already handles 0 prices by returning "0" via toLocaleString(). However, we want to display “Free” / “مجاني” instead of “SAR 0”.

File: src/components/DynamicPlans.astro

Update the Alpine getPrice function in the x-data block (line 42-47):

Before:

getPrice(planId) {
const p = this.plans.find(x => x.id === planId);
if (!p || p.isContactUs) return '';
const val = p[this.currency][this.duration];
return val.toLocaleString();
},

After:

getPrice(planId) {
const p = this.plans.find(x => x.id === planId);
if (!p || p.isContactUs) return '';
const val = p[this.currency][this.duration];
if (val === 0) return '${lang === 'ar' ? 'مجاني' : 'Free'}';
return val.toLocaleString();
},

Also hide the currency symbol and period label when price is 0. Update the price display section for non-contact plans (around line 151-155):

Before:

<div class="flex items-baseline justify-center gap-1" dir="ltr" aria-live="polite">
<span class="text-base font-semibold text-neutral-500" x-text="getCurrencySymbol()"></span>
<span class="text-4xl font-bold text-neutral-900" x-text={`getPrice('${plan.id}')`}></span>
<span class="text-sm text-neutral-500" x-text="getPeriodLabel()"></span>
</div>

After:

<div class="flex items-baseline justify-center gap-1" dir="ltr" aria-live="polite">
<template x-if={`!plans.find(x => x.id === '${plan.id}') || plans.find(x => x.id === '${plan.id}')[currency][duration] !== 0`}>
<span class="text-base font-semibold text-neutral-500" x-text="getCurrencySymbol()"></span>
</template>
<span class="text-4xl font-bold text-neutral-900" x-text={`getPrice('${plan.id}')`}></span>
<template x-if={`!plans.find(x => x.id === '${plan.id}') || plans.find(x => x.id === '${plan.id}')[currency][duration] !== 0`}>
<span class="text-sm text-neutral-500" x-text="getPeriodLabel()"></span>
</template>
</div>

Simpler alternative: Since the Free plan has id: 'free' and always has 0 price, use a direct check:

<div class="flex items-baseline justify-center gap-1" dir="ltr" aria-live="polite">
{plan.id !== 'free' && (
<span class="text-base font-semibold text-neutral-500" x-text="getCurrencySymbol()"></span>
)}
<span class="text-4xl font-bold text-neutral-900" x-text={`getPrice('${plan.id}')`}></span>
{plan.id !== 'free' && (
<span class="text-sm text-neutral-500" x-text="getPeriodLabel()"></span>
)}
</div>

This simpler approach is preferred since plan.id is known at build time.

File: src/components/SEO.astro

Update the offers in softwareSchema (line 155-161):

Before:

"offers": {
"@type": "AggregateOffer",
"lowPrice": "349",
"highPrice": "1999",
"priceCurrency": "SAR",
"offerCount": "4"
},

After:

"offers": {
"@type": "AggregateOffer",
"lowPrice": "0",
"highPrice": "1999",
"priceCurrency": "SAR",
"offerCount": "5"
},
  • Free plan appears as the first (leftmost) card in pricing grid
  • Free plan shows “Free” / “مجاني” instead of “SAR 0” or “USD 0”
  • No currency symbol or period label shown for Free plan
  • Free plan CTA button links to /register (no plan param)
  • 5-column grid renders correctly on xl screens (>1280px)
  • 3-column on lg, 2-column on md, 1-column on mobile
  • Currency and duration toggles have no effect on Free plan display
  • Growth plan still shows “Most Popular” badge
  • npm run build succeeds with 0 errors
  • Both EN and AR pricing pages render all 5 plans correctly

Add Tawk.to widget with language-aware configuration and CSP headers.

File: src/layouts/Layout.astro

Add new environment variable reads at the top of the frontmatter (after line 12):

const tawkPropertyId = import.meta.env.PUBLIC_TAWK_PROPERTY_ID;
const tawkWidgetIdEn = import.meta.env.PUBLIC_TAWK_WIDGET_ID_EN;
const tawkWidgetIdAr = import.meta.env.PUBLIC_TAWK_WIDGET_ID_AR;
const tawkWidgetId = lang === 'ar' ? tawkWidgetIdAr : tawkWidgetIdEn;

Add the Tawk.to script before the closing </body> tag (before line 118), after the scroll animations script and before </body>:

{/* Tawk.to Live Chat */}
{tawkPropertyId && tawkWidgetId && (
<script define:vars={{ tawkPropertyId, tawkWidgetId }}>
var Tawk_API = Tawk_API || {};
var Tawk_LoadStart = new Date();
(function(){
var s1 = document.createElement("script");
var s0 = document.getElementsByTagName("script")[0];
s1.async = true;
s1.src = 'https://embed.tawk.to/' + tawkPropertyId + '/' + tawkWidgetId;
s1.charset = 'UTF-8';
s1.setAttribute('crossorigin', '*');
s0.parentNode.insertBefore(s1, s0);
})();
</script>
)}
</body>

Important: The script only loads when both env vars are set. In development without env vars, no Tawk.to widget appears.

File: public/_headers

Update the Content-Security-Policy header (line 6). Add Tawk.to domains to the relevant directives:

Current CSP (single line, key sections shown):

script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com ... https://connect.facebook.net;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
connect-src 'self' https://www.googletagmanager.com ... https://connect.facebook.net;
frame-src https://challenges.cloudflare.com https://www.facebook.com;
img-src 'self' data: https: blob:;

Add the following domains to each directive:

DirectiveAdd
script-srchttps://embed.tawk.to
style-src(no change needed — Tawk injects inline styles, already covered by 'unsafe-inline')
connect-srchttps://va.tawk.to https://embed.tawk.to
frame-srchttps://tawk.to https://*.tawk.to
img-src(no change needed — already has https:)
font-src(no change needed — already has data:)

Updated CSP line (full replacement for line 6):

Content-Security-Policy: default-src 'self'; img-src 'self' data: https: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://googletagmanager.com https://www.google-analytics.com https://static.cloudflareinsights.com https://challenges.cloudflare.com https://cdn.jsdelivr.net https://connect.facebook.net https://embed.tawk.to; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://www.googletagmanager.com https://www.google-analytics.com https://static.cloudflareinsights.com https://challenges.cloudflare.com https://formspree.io https://api.urwhats.com https://app.urwhats.com https://www.facebook.com https://connect.facebook.net https://va.tawk.to https://embed.tawk.to; frame-src https://challenges.cloudflare.com https://www.facebook.com https://tawk.to https://*.tawk.to;

File: src/env.d.ts

Add the new env variables:

Before:

interface ImportMetaEnv {
readonly PUBLIC_SITE_URL: string;
readonly PUBLIC_FORMSPARK_FORM_ID: string;
readonly PUBLIC_TURNSTILE_SITE_KEY: string;
readonly PUBLIC_GTM_ID?: string;
readonly PUBLIC_REGISTRATION_API_KEY?: string;
}

After:

interface ImportMetaEnv {
readonly PUBLIC_SITE_URL: string;
readonly PUBLIC_FORMSPARK_FORM_ID: string;
readonly PUBLIC_TURNSTILE_SITE_KEY: string;
readonly PUBLIC_GTM_ID?: string;
readonly PUBLIC_REGISTRATION_API_KEY?: string;
readonly PUBLIC_TAWK_PROPERTY_ID?: string;
readonly PUBLIC_TAWK_WIDGET_ID_EN?: string;
readonly PUBLIC_TAWK_WIDGET_ID_AR?: string;
}
  • Tawk.to widget appears on all pages when env vars are set
  • Widget does NOT appear when env vars are missing (dev mode safe)
  • English pages load the EN widget ID, Arabic pages load the AR widget ID
  • No CSP violations in browser console on any page
  • Widget does not block page rendering (script is async)
  • npm run build succeeds with 0 errors
  • Widget is accessible (keyboard navigable)

Create a reusable YouTube embed component with poster image, play button overlay, and privacy-enhanced mode.

File: src/components/VideoSection.astro (NEW)

---
/**
* VideoSection - YouTube embed with poster image and play overlay.
* Uses youtube-nocookie.com for privacy-enhanced mode.
* Lazy-loads the iframe only when user clicks play.
*
* Props:
* videoId - YouTube video ID (e.g., "dQw4w9WgXcQ")
* posterSrc - Path to poster image (optional, falls back to YouTube thumbnail)
* posterAlt - Alt text for poster image
* lang - 'en' | 'ar' for button labels
* title - Section title (optional)
* subtitle - Section subtitle (optional)
*/
interface Props {
videoId: string;
posterSrc?: string;
posterAlt?: string;
lang: 'en' | 'ar';
title?: string;
subtitle?: string;
}
const {
videoId,
posterSrc,
posterAlt = 'Video thumbnail',
lang,
title,
subtitle,
} = Astro.props;
const thumbnailUrl = posterSrc || `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
const iframeSrc = `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0&modestbranding=1`;
const playLabel = lang === 'ar' ? 'تشغيل الفيديو' : 'Play video';
---
<section class="py-12 sm:py-16">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{(title || subtitle) && (
<div class="text-center mb-10 max-w-3xl mx-auto">
{title && <h2 class="text-3xl sm:text-4xl font-bold text-neutral-900 mb-3">{title}</h2>}
{subtitle && <p class="text-lg text-neutral-500">{subtitle}</p>}
</div>
)}
<div
class="relative max-w-4xl mx-auto rounded-2xl overflow-hidden shadow-xl border border-neutral-200 bg-neutral-900"
x-data="{ playing: false }"
>
{/* Poster + play button (shown before click) */}
<div
x-show="!playing"
class="relative cursor-pointer group"
@click="playing = true"
role="button"
tabindex="0"
:aria-label={`'${playLabel}'`}
@keydown.enter="playing = true"
@keydown.space.prevent="playing = true"
>
{/* 16:9 aspect ratio container */}
<div class="relative w-full" style="padding-bottom: 56.25%;">
<img
src={thumbnailUrl}
alt={posterAlt}
class="absolute inset-0 w-full h-full object-cover"
width="1280"
height="720"
loading="lazy"
/>
{/* Dark overlay */}
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors duration-300"></div>
{/* Play button */}
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-16 h-16 sm:w-20 sm:h-20 bg-white/90 group-hover:bg-white rounded-full flex items-center justify-center shadow-2xl transition-all duration-300 group-hover:scale-110">
<svg class="w-7 h-7 sm:w-8 sm:h-8 text-primary-600 ms-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
</div>
</div>
{/* Iframe (loaded on click) */}
<template x-if="playing">
<div class="relative w-full" style="padding-bottom: 56.25%;">
<iframe
class="absolute inset-0 w-full h-full"
src={iframeSrc}
title={posterAlt}
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
></iframe>
</div>
</template>
</div>
</div>
</section>

File: src/pages/index.astro

Add import at top of frontmatter:

import VideoSection from '../components/VideoSection.astro';

Place between the “Why Choose + Features” section and the “Pricing CTA” section (between lines 227 and 229):

<!-- Product Demo Video -->
<VideoSection
videoId="REPLACE_WITH_ACTUAL_VIDEO_ID"
lang={lang}
title={lang === 'ar' ? 'شاهد المنصة أثناء العمل' : 'See the Platform in Action'}
subtitle={lang === 'ar' ? 'اكتشف كيف يمكن لـ urWhats تحويل تواصلك مع العملاء' : 'Discover how urWhats can transform your customer communication'}
/>

File: src/pages/ar/index.astro — same addition at the equivalent position.

File: public/_headers

Add https://www.youtube-nocookie.com and https://img.youtube.com to the CSP:

DirectiveAdd
frame-srchttps://www.youtube-nocookie.com
img-src(already covered by https:)

Update the frame-src in the CSP line to include:

frame-src https://challenges.cloudflare.com https://www.facebook.com https://tawk.to https://*.tawk.to https://www.youtube-nocookie.com;
  • VideoSection renders poster image with play button overlay
  • Clicking play loads YouTube iframe with autoplay
  • Keyboard accessible (Enter/Space triggers play)
  • 16:9 aspect ratio maintained at all viewport sizes
  • Uses youtube-nocookie.com (privacy-enhanced mode)
  • Iframe only loads on user interaction (no initial YouTube request)
  • No CSP violations for YouTube embed
  • Component works in both LTR and RTL layouts
  • npm run build succeeds with 0 errors
  • Replace REPLACE_WITH_ACTUAL_VIDEO_ID with real video before deploying

Document and implement GTM dataLayer.push events for key conversion actions.

This event fires on the app (app.urwhats.com), not on the marketing site. Document for the app team:

GTM Tag: Custom Event — registration_complete

// Fires on app.urwhats.com after successful registration
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'registration_complete',
method: 'email', // or 'google', 'phone'
plan_name: 'starter', // plan selected during registration
plan_id: '3',
currency: 'SAR',
});

File: src/components/DynamicPlans.astro

Add a data-plan-name attribute to CTA buttons and fire a dataLayer event on click. Modify each CTA <a> tag (lines 173-187):

Add @click handler to all plan CTA links. The simplest approach is adding a click handler via a script at the bottom of the component.

Add before the closing </div> of the component (before line 240):

<script>
document.querySelectorAll('[data-plan-id]').forEach((el) => {
el.addEventListener('click', () => {
const planId = el.getAttribute('data-plan-id');
const planName = el.getAttribute('data-plan-name');
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'plan_selected',
plan_id: planId,
plan_name: planName,
});
});
});
</script>

Then add data-plan-id and data-plan-name attributes to each CTA button. Modify the three <a> blocks (contact, trial, subscribe) to include:

data-plan-id={plan.id}
data-plan-name={L({ en: plan.nameEn, ar: plan.nameAr })}

For example, the trial CTA (line 177-181) becomes:

<a href={planCtaUrls[plan.id]}
data-plan-id={plan.id}
data-plan-name={L({ en: plan.nameEn, ar: plan.nameAr })}
class="block w-full text-center py-3 px-6 rounded-full font-semibold text-sm transition-all border-2 border-primary-500 text-primary-600 hover:bg-primary-500 hover:text-white">
{L(pricingLabels.startTrial)}
</a>

Apply the same data-plan-id and data-plan-name to all three CTA variants (contact, trial, subscribe).

File: src/components/Navigation.astro

The existing language switch script (lines 296-303) already handles click events. Extend it to push a dataLayer event:

Before:

<script>
document.querySelectorAll<HTMLAnchorElement>('[data-lang-switch]').forEach((link) => {
link.addEventListener('click', () => {
const lang = link.dataset.langSwitch;
document.cookie = `lang=${lang}; path=/; max-age=2592000; SameSite=Lax`;
});
});
</script>

After:

<script>
document.querySelectorAll<HTMLAnchorElement>('[data-lang-switch]').forEach((link) => {
link.addEventListener('click', () => {
const lang = link.dataset.langSwitch;
document.cookie = `lang=${lang}; path=/; max-age=2592000; SameSite=Lax`;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'language_switch',
new_language: lang,
});
});
});
</script>

This is a GTM container configuration, not a code change. Document for the GTM admin:

GTM Configuration — Cross-Domain Tracking

  1. In GTM, go to Tags > Google Tag (GA4 Configuration)
  2. Under Configuration Settings, add parameter:
    • Parameter: linker
    • Value: { "domains": ["urwhats.com", "app.urwhats.com"] }
  3. Or use the Cross Domain feature in GA4 Admin:
    • Go to GA4 Admin > Data Streams > Web > Configure Tag Settings > Configure Your Domains
    • Add: urwhats.com and app.urwhats.com
  4. This ensures the _gl parameter is appended when navigating between the marketing site and the app.

GTM Triggers to create:

Trigger NameTypeFires on
CE - plan_selectedCustom EventEvent name = plan_selected
CE - language_switchCustom EventEvent name = language_switch
CE - registration_completeCustom EventEvent name = registration_complete

GTM Variables to create (Data Layer):

Variable NameData Layer Variable Name
dlv - plan_idplan_id
dlv - plan_nameplan_name
dlv - new_languagenew_language
dlv - methodmethod
dlv - currencycurrency
  • Clicking any plan CTA fires plan_selected with correct plan_id and plan_name
  • Clicking language switcher fires language_switch with new_language value
  • window.dataLayer is initialized before push (defensive || [])
  • Events visible in GTM Preview mode
  • No JavaScript errors in console on any page
  • npm run build succeeds with 0 errors
  • Cross-domain linker documented for GTM admin

Add ArticleSchema to SEO.astro for blog posts, with author, dates, and og:type override.

File: src/components/SEO.astro

The SEO component needs to detect blog posts and render Article schema. Add optional props for blog post data.

Add after line 6 (const contactEmail = ...):

// Blog post data (passed from blog post template pages)
interface BlogPostData {
title: string;
description: string;
date: Date;
updatedDate?: Date;
author: string;
category: string;
tags: string[];
image?: { src: string; alt: string };
}
const blogPost: BlogPostData | undefined = Astro.props.blogPost;
const isBlogPost = !!blogPost;

File: src/components/SEO.astro

Change the type variable (line 12):

Before:

const type = 'website';

After:

const type = isBlogPost ? 'article' : 'website';

File: src/components/SEO.astro

Add after the existing Open Graph tags (after line 401, after og:site_name):

{/* Article-specific Open Graph tags */}
{isBlogPost && blogPost && (
<>
<meta property="article:published_time" content={blogPost.date.toISOString()} />
{blogPost.updatedDate && (
<meta property="article:modified_time" content={blogPost.updatedDate.toISOString()} />
)}
<meta property="article:author" content={blogPost.author} />
<meta property="article:section" content={blogPost.category} />
{blogPost.tags.map((tag: string) => (
<meta property="article:tag" content={tag} />
))}
</>
)}

File: src/components/SEO.astro

Add after the faqSchema definition (after line 298) and before the webPageSchema:

// Article Schema (only for blog posts)
const articleSchema = isBlogPost && blogPost ? {
"@type": "Article",
"@id": `${canonicalURL}#article`,
"headline": blogPost.title,
"description": blogPost.description,
"image": blogPost.image
? new URL(blogPost.image.src, siteUrl).toString()
: socialImageURL.toString(),
"datePublished": blogPost.date.toISOString(),
"dateModified": (blogPost.updatedDate ?? blogPost.date).toISOString(),
"author": {
"@type": "Person",
"name": blogPost.author,
},
"publisher": { "@id": `${siteUrl}/#organization` },
"mainEntityOfPage": { "@id": canonicalURL.toString() },
"isPartOf": { "@id": `${siteUrl}/#website` },
"inLanguage": lang === 'ar' ? "ar-SA" : "en-US",
"articleSection": blogPost.category,
"keywords": blogPost.tags.join(', '),
} : null;

Then add it to the @graph array (line 322-330):

Before:

const graphJsonLd = {
"@context": "https://schema.org",
"@graph": [
organizationSchema,
websiteSchema,
softwareSchema,
serviceSchema,
breadcrumbSchema,
webPageSchema,
...(faqSchema ? [faqSchema] : [])
]
};

After:

const graphJsonLd = {
"@context": "https://schema.org",
"@graph": [
organizationSchema,
websiteSchema,
...(isBlogPost ? [] : [softwareSchema, serviceSchema]),
breadcrumbSchema,
webPageSchema,
...(faqSchema ? [faqSchema] : []),
...(articleSchema ? [articleSchema] : []),
]
};

Note: softwareSchema and serviceSchema are excluded from blog post pages since they are not relevant.

7.5 Pass blogPost prop from blog post templates

Section titled “7.5 Pass blogPost prop from blog post templates”

File: src/pages/blog/[...slug].astro

Update the Layout usage to pass blogPost data:

<Layout lang={lang} title={`${post.data.title} | urWhats Blog`} blogPost={post.data}>

File: src/pages/ar/blog/[...slug].astro — same change:

<Layout lang={lang} title={`${post.data.title} | مدونة urWhats`} blogPost={post.data}>

File: src/layouts/Layout.astro

Update props destructuring (line 10):

Before:

const { lang, title } = Astro.props;

After:

const { lang, title, blogPost } = Astro.props;

Update the SEO component usage (line 21):

Before:

<SEO lang={lang} title={title}/>

After:

<SEO lang={lang} title={title} blogPost={blogPost}/>
  • Blog posts render og:type = "article" (not "website")
  • article:published_time meta tag present with ISO date
  • article:author meta tag present
  • article:tag meta tags present for each tag
  • JSON-LD contains Article schema with correct headline, datePublished, author
  • JSON-LD for blog posts does NOT include SoftwareApplication or Service schemas
  • Non-blog pages still render og:type = "website" (no regression)
  • Rich Results Test passes for blog post pages
  • npm run build succeeds with 0 errors

Update homepage CTA section text, pricing page supporting text, and ensure consistent CTA messaging across all pages.

File: public/assets/i18n/en.json

Update the home pricing section keys used by the CTASection on the homepage:

Find and update these keys:

"home.pricing.title": "Ready to Transform Your WhatsApp Communication?"

Change to:

"home.pricing.title": "Start Growing with WhatsApp Today"
"home.pricing.subtitle": "..."

Change to:

"home.pricing.subtitle": "250 free messages. No credit card. Set up in 5 minutes."

File: public/assets/i18n/ar.json

Matching updates:

"home.pricing.title": "ابدأ بتنمية أعمالك عبر واتساب اليوم"
"home.pricing.subtitle": "٢٥٠ رسالة مجانية. بدون بطاقة ائتمان. إعداد في ٥ دقائق."

Files: src/pages/prices.astro and src/pages/ar/prices.astro

Both files have a CTASection at the bottom. Update the title and subtitle props:

Before (prices.astro, around line 26-30):

<CTASection
title={lang === 'ar' ? '...' : '...'}
subtitle={lang === 'ar' ? 'ابدأ تجربتك المجانية لمدة 14 يومًا بدون بطاقة ائتمان' : 'Start your 14-day free trial. No credit card required.'}
primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Start Free Trial'}
...

After:

<CTASection
title={lang === 'ar' ? 'ابدأ مجاناً اليوم' : 'Get Started Free Today'}
subtitle={lang === 'ar' ? '٢٥٠ رسالة مجانية للبدء. بدون بطاقة ائتمان.' : '250 free messages to get started. No credit card required.'}
primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}
...

8.3 Consistent CTA supporting text pattern

Section titled “8.3 Consistent CTA supporting text pattern”

Establish a standard pattern for all CTASection components across all pages:

Standard EN supporting text options:

  • Primary: "250 free messages to get started. No credit card required."
  • Secondary: "Join hundreds of Saudi businesses using urWhats."
  • Short: "No credit card required."

Standard AR supporting text options:

  • Primary: "٢٥٠ رسالة مجانية للبدء. بدون بطاقة ائتمان."
  • Secondary: "انضم لمئات الشركات السعودية التي تستخدم urWhats."
  • Short: "بدون بطاقة ائتمان."

Apply to all pages that have a CTASection:

Pagesubtitle (use)
index.astroPrimary
features.astroPrimary
solutions.astroSecondary
prices.astroPrimary
integrations.astroPrimary
faqs.astroSecondary

Update each file’s CTASection subtitle prop accordingly.

  • Homepage CTA section shows updated title and subtitle
  • Pricing page CTA shows “250 free messages” messaging
  • All CTASection subtitles are consistent and use one of the standard patterns
  • No references to “14-day” trial remain
  • Both EN and AR versions match in meaning
  • npm run build succeeds with 0 errors

Task 9: Environment Variables Documentation

Section titled “Task 9: Environment Variables Documentation”

Add Tawk.to env vars to .env.example and env.d.ts.

File: .env.example

Before:

# Site URL (required)
PUBLIC_SITE_URL=https://urwhats.com
# Google Tag Manager (optional)
PUBLIC_GTM_ID=
# Formspree (Contact Form)
PUBLIC_FORMSPARK_FORM_ID=
# Cloudflare Turnstile (CAPTCHA)
PUBLIC_TURNSTILE_SITE_KEY=

After:

# Site URL (required)
PUBLIC_SITE_URL=https://urwhats.com
# Google Tag Manager (optional)
PUBLIC_GTM_ID=
# Formspree (Contact Form)
PUBLIC_FORMSPARK_FORM_ID=
# Cloudflare Turnstile (CAPTCHA)
PUBLIC_TURNSTILE_SITE_KEY=
# Tawk.to Live Chat (optional)
# Create widgets at https://dashboard.tawk.to — one EN, one AR
PUBLIC_TAWK_PROPERTY_ID=
PUBLIC_TAWK_WIDGET_ID_EN=
PUBLIC_TAWK_WIDGET_ID_AR=

9.2 Verify env.d.ts (already done in Task 4.3)

Section titled “9.2 Verify env.d.ts (already done in Task 4.3)”

File: src/env.d.ts

Confirm these lines are present (added in Task 4.3):

readonly PUBLIC_TAWK_PROPERTY_ID?: string;
readonly PUBLIC_TAWK_WIDGET_ID_EN?: string;
readonly PUBLIC_TAWK_WIDGET_ID_AR?: string;

9.3 Document Cloudflare Pages env var setup

Section titled “9.3 Document Cloudflare Pages env var setup”

For deployment, set these environment variables in the Cloudflare Pages dashboard:

VariableRequiredDescription
PUBLIC_SITE_URLYeshttps://urwhats.com
PUBLIC_GTM_IDNoGoogle Tag Manager container ID
PUBLIC_FORMSPARK_FORM_IDYesFormspree form ID for contact form
PUBLIC_TURNSTILE_SITE_KEYYesCloudflare Turnstile CAPTCHA key
PUBLIC_TAWK_PROPERTY_IDNoTawk.to property ID (from dashboard)
PUBLIC_TAWK_WIDGET_ID_ENNoTawk.to English widget ID
PUBLIC_TAWK_WIDGET_ID_ARNoTawk.to Arabic widget ID

How to find Tawk.to IDs:

  1. Log into https://dashboard.tawk.to
  2. Go to Settings > Chat Widget
  3. The embed code contains: https://embed.tawk.to/{PROPERTY_ID}/{WIDGET_ID}
  4. Create two widgets: one for English, one for Arabic
  5. Set the property ID and both widget IDs in Cloudflare Pages env vars
  • .env.example contains all 7 env variables with comments
  • src/env.d.ts declares all env vars with correct types
  • All Tawk.to env vars are marked optional (?)
  • No actual secrets/keys appear in .env.example (only empty values)
  • Cloudflare Pages setup instructions are documented above

Tasks can be executed in dependency order:

Task 1 (Blog Infrastructure) ─── depends on nothing
Task 2 (CTA Text Replacement) ─── depends on nothing
Task 3 (Free Tier) ─── depends on nothing
Task 4 (Tawk.to) ─── depends on nothing
Task 5 (VideoSection) ─── depends on nothing
Task 6 (GTM DataLayer) ─── depends on nothing
Task 7 (Blog SEO) ─── depends on Task 1 (blog infrastructure must exist)
Task 8 (Content Updates) ─── depends on Task 2 (CTA text changes)
Task 9 (Env Variables) ─── depends on Task 4 (Tawk.to env vars)

Recommended batch execution:

  1. Batch 1 (parallel): Tasks 1, 2, 3, 4, 5, 6
  2. Batch 2 (sequential): Tasks 7, 8, 9

After all tasks: run npm run clean && npm run build to verify 0 errors across all 22+ pages.


All files created or modified across all 9 tasks:

FileTasksAction
package.json1Modify (add @astrojs/mdx)
astro.config.mjs1Modify (add mdx integration)
src/content/config.ts1Create
src/content/blog/en/*.mdx1Create
src/content/blog/ar/*.mdx1Create
src/pages/blog/index.astro1Create
src/pages/ar/blog/index.astro1Create
src/pages/blog/[...slug].astro1, 7Create
src/pages/ar/blog/[...slug].astro1, 7Create
src/components/Navigation.astro1, 6Modify
src/components/SEO.astro2, 3, 7Modify
src/components/DynamicPlans.astro3, 6Modify
src/components/VideoSection.astro5Create
src/config/static-plans.config.ts2, 3Modify
src/layouts/Layout.astro4, 7Modify
src/env.d.ts4, 9Modify
src/pages/index.astro5Modify
src/pages/ar/index.astro5Modify
src/pages/features.astro2Modify
src/pages/ar/features.astro2Modify
src/pages/solutions.astro2Modify
src/pages/ar/solutions.astro2Modify
src/pages/integrations.astro2Modify
src/pages/ar/integrations.astro2Modify
src/pages/prices.astro2, 8Modify
src/pages/ar/prices.astro2, 8Modify
src/pages/faqs.astro2Modify
src/pages/ar/faqs.astro2Modify
public/assets/i18n/en.json1, 2, 8Modify
public/assets/i18n/ar.json1, 2, 8Modify
public/_headers4, 5Modify
.env.example9Modify