Symfony 8 SPA with Doctrine ORM, Symfony Security, vanilla JS frontend. Migrated from plain PHP (delight-im/auth + raw SQL) to full Symfony stack. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
578 lines
21 KiB
Markdown
578 lines
21 KiB
Markdown
# Registration & Invite System Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add invite-only registration, referral tracking, and password reset to Zieltracker.
|
|
|
|
**Architecture:** Vanilla JS single-page app. A new `config.js` externalizes server URLs. PocketBase handles auth, email verification, and password reset natively via SMTP. A new `invites` collection tracks tokens with labels and referral state. All new UI flows (register tab, password reset, invite management) are added to `app.js` following the existing sheet pattern.
|
|
|
|
**Tech Stack:** Vanilla JS, PocketBase REST API, Brevo SMTP (configured in PocketBase admin)
|
|
|
|
> **Note:** This app has no test framework. Each task uses browser-based manual verification instead of automated tests.
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
| File | Action | Responsibility |
|
|
|---|---|---|
|
|
| `config.js` | Create | `PB_URL` and `APP_URL` globals |
|
|
| `index.html` | Modify | Add `<script src="config.js">` before `app.js` |
|
|
| `app.js` | Modify | Remove hardcoded `PB_URL`; add `inviteRecord`/`loginTab` globals; refactor `showLogin`; add password reset, registration, and invite management functions; update startup block |
|
|
| `style.css` | Modify | Add tab styles, password-reset link style, invite list/link styles |
|
|
|
|
---
|
|
|
|
### Task 1: Create config.js and wire it up
|
|
|
|
**Files:**
|
|
- Create: `config.js`
|
|
- Modify: `index.html`
|
|
- Modify: `app.js` (remove line 4)
|
|
|
|
- [ ] **Step 1: Create `config.js`**
|
|
|
|
```js
|
|
var PB_URL = 'http://miniweb.kuehn.home:8090';
|
|
var APP_URL = 'http://miniweb.kuehn.home';
|
|
```
|
|
|
|
- [ ] **Step 2: Add script tag to `index.html`**
|
|
|
|
In `index.html`, before `<script src="app.js"></script>`, add:
|
|
|
|
```html
|
|
<script src="config.js"></script>
|
|
```
|
|
|
|
- [ ] **Step 3: Remove hardcoded `PB_URL` from `app.js`**
|
|
|
|
Delete line 4 of `app.js`:
|
|
```js
|
|
var PB_URL = 'http://miniweb.kuehn.home:8090';
|
|
```
|
|
|
|
- [ ] **Step 4: Verify in browser**
|
|
|
|
Open the app. Goals load, login/logout works, no console errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add config.js index.html app.js
|
|
git commit -m "feat: externalize server URLs into config.js"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: PocketBase — create `invites` collection (manual)
|
|
|
|
**Files:** None — PocketBase admin UI only
|
|
|
|
- [ ] **Step 1: Open PocketBase admin**
|
|
|
|
Navigate to `http://miniweb.kuehn.home:8090/_/` and log in.
|
|
|
|
- [ ] **Step 2: Create `invites` collection**
|
|
|
|
Collections → New collection → Name: `invites`
|
|
|
|
Add fields:
|
|
|
|
| Name | Type | Options |
|
|
|---|---|---|
|
|
| `token` | Text | Required |
|
|
| `label` | Text | Required |
|
|
| `created_by` | Relation | Collection: `users`, Required |
|
|
| `used_by` | Relation | Collection: `users`, Optional |
|
|
| `used_at` | Date | Optional |
|
|
|
|
- [ ] **Step 3: Set API rules**
|
|
|
|
In the `invites` collection → API rules:
|
|
|
|
| Rule | Value |
|
|
|---|---|
|
|
| List / View | *(empty — public)* |
|
|
| Create | `@request.auth.id != ""` |
|
|
| Update | `used_by = ""` |
|
|
| Delete | `@request.auth.id = created_by` |
|
|
|
|
- [ ] **Step 4: Verify**
|
|
|
|
In PocketBase admin, create a test invite record manually with any token string. Then open in browser:
|
|
`http://miniweb.kuehn.home:8090/api/collections/invites/records`
|
|
Should return the record as JSON.
|
|
|
|
---
|
|
|
|
### Task 3: PocketBase — configure SMTP (manual)
|
|
|
|
**Files:** None — PocketBase admin UI only
|
|
|
|
- [ ] **Step 1: Get Brevo SMTP credentials**
|
|
|
|
Sign up at brevo.com (free tier). Go to:
|
|
SMTP & API → SMTP → Generate SMTP key
|
|
|
|
Note: host `smtp-relay.brevo.com`, port `587`, your Brevo login email, and the generated key.
|
|
|
|
- [ ] **Step 2: Configure PocketBase SMTP**
|
|
|
|
PocketBase admin → Settings → Mail settings:
|
|
|
|
| Field | Value |
|
|
|---|---|
|
|
| Sender name | ZielTracker |
|
|
| Sender address | your-email@example.com |
|
|
| SMTP host | smtp-relay.brevo.com |
|
|
| SMTP port | 587 |
|
|
| Username | your-brevo-login@example.com |
|
|
| Password | (Brevo SMTP key) |
|
|
| TLS | enabled |
|
|
|
|
- [ ] **Step 3: Send test email**
|
|
|
|
PocketBase admin → Settings → Mail settings → "Send test email". Confirm arrival in inbox.
|
|
|
|
---
|
|
|
|
### Task 4: Add CSS for new UI elements
|
|
|
|
**Files:**
|
|
- Modify: `style.css`
|
|
|
|
- [ ] **Step 1: Append styles to `style.css`**
|
|
|
|
```css
|
|
.ltabs{display:flex;gap:0;margin:0 0 16px;border-bottom:2px solid var(--border)}
|
|
.ltab{flex:1;padding:10px;background:none;border:none;font-size:15px;color:var(--text3);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-2px;transition:color .15s;font-family:'DM Sans',sans-serif}
|
|
.ltab.active{color:var(--text);border-bottom-color:var(--text)}
|
|
.pw-link{background:none;border:none;color:var(--text3);font-size:13px;padding:8px 0 0;text-decoration:underline;cursor:pointer;display:block;text-align:center;font-family:'DM Sans',sans-serif;width:100%}
|
|
.inv-row{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid var(--border)}
|
|
.inv-lbl{font-weight:500;font-size:15px}
|
|
.inv-link{background:var(--bg3);border:1px solid var(--border2);border-radius:var(--rs);padding:12px;font-size:13px;word-break:break-all;margin:8px 0 16px;font-family:'DM Mono',monospace;line-height:1.5}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify**
|
|
|
|
Open app in browser, confirm no visual regressions.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add style.css
|
|
git commit -m "feat: add CSS for login tabs, password-reset link, and invite rows"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Add globals and refactor startup block
|
|
|
|
**Files:**
|
|
- Modify: `app.js`
|
|
|
|
- [ ] **Step 1: Add globals near top of `app.js`**
|
|
|
|
After line 3 (`var authToken = '', authUserId = '';`), insert:
|
|
|
|
```js
|
|
var inviteRecord = null;
|
|
var loginTab = 'login';
|
|
```
|
|
|
|
- [ ] **Step 2: Replace the startup block at the bottom of `app.js`**
|
|
|
|
The current startup block (after `scheduleMidnight` and `visibilitychange`) is:
|
|
|
|
```js
|
|
authToken=localStorage.getItem('zt_token')||'';
|
|
authUserId=localStorage.getItem('zt_uid')||'';
|
|
|
|
if(!authToken){
|
|
render(); showLogin();
|
|
} else {
|
|
pbGoals()
|
|
.then(function(g){ goals=g; render(); })
|
|
.catch(function(){ clearAuth(); render(); showLogin(); });
|
|
}
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```js
|
|
authToken=localStorage.getItem('zt_token')||'';
|
|
authUserId=localStorage.getItem('zt_uid')||'';
|
|
|
|
function init(){
|
|
if(!authToken){ render(); showLogin(); }
|
|
else{
|
|
pbGoals()
|
|
.then(function(g){ goals=g; render(); })
|
|
.catch(function(){ clearAuth(); render(); showLogin(); });
|
|
}
|
|
}
|
|
|
|
var urlInviteToken=(new URLSearchParams(window.location.search)).get('invite');
|
|
if(urlInviteToken){
|
|
api('GET','/api/collections/invites/records?filter='+encodeURIComponent('(token="'+urlInviteToken+'"&&used_by="")'))
|
|
.then(function(res){
|
|
if(res.items&&res.items.length>0) inviteRecord=res.items[0];
|
|
})
|
|
.catch(function(){})
|
|
.then(function(){ init(); });
|
|
}else{
|
|
init();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify**
|
|
|
|
Open app normally (no `?invite` param). Works as before, no console errors.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add app.js
|
|
git commit -m "feat: add invite globals and refactored startup with URL token check"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Password reset UI
|
|
|
|
**Files:**
|
|
- Modify: `app.js`
|
|
|
|
- [ ] **Step 1: Add `showPasswordReset` function after `showLogin` in `app.js`**
|
|
|
|
```js
|
|
function showPasswordReset(){
|
|
var o=document.getElementById('ov');
|
|
o.innerHTML='<div class="sheet">'+
|
|
'<div class="shandle"></div>'+
|
|
'<div class="stitle">Passwort zurücksetzen</div>'+
|
|
'<div class="ssub">Wir senden dir einen Reset-Link per E-Mail</div>'+
|
|
'<div class="ff"><label>E-Mail</label><input class="fi" id="premail" type="email" autocomplete="email"/></div>'+
|
|
'<div class="factions">'+
|
|
'<button class="btn-p" id="prsub">Link senden</button>'+
|
|
'<button class="btn-c" id="prback">Zurück</button>'+
|
|
'</div>'+
|
|
'</div>';
|
|
o.onclick=null;
|
|
setTimeout(function(){ var el=document.getElementById('premail'); if(el) el.focus(); },50);
|
|
document.getElementById('prback').onclick=function(){ loginTab='login'; showLogin(); };
|
|
document.getElementById('prsub').onclick=function(){
|
|
var email=document.getElementById('premail').value.trim();
|
|
if(!email) return;
|
|
var btn=this; btn.disabled=true; btn.textContent='…';
|
|
api('POST','/api/collections/users/request-password-reset',{email:email})
|
|
.then(function(){
|
|
document.querySelector('#ov .sheet').innerHTML=
|
|
'<div class="shandle"></div>'+
|
|
'<div class="stitle">E-Mail gesendet</div>'+
|
|
'<div class="ssub">Falls ein Konto mit dieser Adresse existiert, hast du einen Link erhalten.</div>'+
|
|
'<div class="factions"><button class="btn-p" id="prdone">Zum Login</button></div>';
|
|
document.getElementById('prdone').onclick=function(){ loginTab='login'; showLogin(); };
|
|
})
|
|
.catch(function(){
|
|
btn.disabled=false; btn.textContent='Link senden';
|
|
showToast('Fehler beim Senden');
|
|
});
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify**
|
|
|
|
Open app. The login sheet still works. (The "Passwort vergessen?" link is wired in the next task.)
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add app.js
|
|
git commit -m "feat: add password reset sheet"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Login sheet refactor + registration form
|
|
|
|
> All six functions in this task must be added together in one commit — `showLogin` calls `renderRegisterForm` and `wireRegisterForm` which are defined here.
|
|
|
|
**Files:**
|
|
- Modify: `app.js`
|
|
|
|
- [ ] **Step 1: Replace the entire `showLogin` function and add all six helper functions**
|
|
|
|
Replace the existing `showLogin` function in `app.js` with the following block. This is a complete replacement — remove the old function and insert all six functions in its place:
|
|
|
|
```js
|
|
function showLogin(err){
|
|
var o=document.getElementById('ov');
|
|
o.style.cssText='display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;animation:fi .2s ease';
|
|
var hasInvite=!!inviteRecord;
|
|
var tabsHtml=hasInvite
|
|
?'<div class="ltabs">'+
|
|
'<button class="ltab'+(loginTab==='login'?' active':'')+'" id="ltab-login">Anmelden</button>'+
|
|
'<button class="ltab'+(loginTab==='register'?' active':'')+'" id="ltab-reg">Registrieren</button>'+
|
|
'</div>'
|
|
:'';
|
|
var formHtml=(hasInvite&&loginTab==='register')?renderRegisterForm(err):renderLoginForm(err);
|
|
o.innerHTML='<div class="sheet">'+
|
|
'<div class="shandle"></div>'+
|
|
'<div class="stitle">Anmelden</div>'+
|
|
'<div class="ssub">ZielTracker</div>'+
|
|
tabsHtml+formHtml+
|
|
'</div>';
|
|
o.onclick=null;
|
|
if(hasInvite){
|
|
document.getElementById('ltab-login').onclick=function(){loginTab='login';showLogin();};
|
|
document.getElementById('ltab-reg').onclick=function(){loginTab='register';showLogin();};
|
|
}
|
|
if(hasInvite&&loginTab==='register') wireRegisterForm();
|
|
else wireLoginForm();
|
|
}
|
|
|
|
function renderLoginForm(err){
|
|
return (err?'<div class="login-err">'+err+'</div>':'')+
|
|
'<div class="ff"><label>E-Mail</label><input class="fi" id="lemail" type="email" autocomplete="email"/></div>'+
|
|
'<div class="ff"><label>Passwort</label><input class="fi" id="lpass" type="password" autocomplete="current-password"/></div>'+
|
|
'<div class="factions"><button class="btn-p" id="lsub">Anmelden</button></div>'+
|
|
'<button class="pw-link" id="lpw">Passwort vergessen?</button>';
|
|
}
|
|
|
|
function wireLoginForm(){
|
|
setTimeout(function(){
|
|
var le=document.getElementById('lemail'),lp=document.getElementById('lpass');
|
|
if(le){le.focus();le.onkeydown=function(e){if(e.key==='Enter')lp.focus();};}
|
|
if(lp) lp.onkeydown=function(e){if(e.key==='Enter')document.getElementById('lsub').click();};
|
|
var lpw=document.getElementById('lpw');
|
|
if(lpw) lpw.onclick=showPasswordReset;
|
|
},50);
|
|
document.getElementById('lsub').onclick=function(){
|
|
var email=document.getElementById('lemail').value.trim();
|
|
var pass=document.getElementById('lpass').value;
|
|
if(!email||!pass) return;
|
|
var btn=this; btn.disabled=true; btn.textContent='…';
|
|
api('POST','/api/collections/users/auth-with-password',{identity:email,password:pass})
|
|
.then(function(res){
|
|
authToken=res.token; authUserId=res.record.id;
|
|
localStorage.setItem('zt_token',authToken);
|
|
localStorage.setItem('zt_uid',authUserId);
|
|
return pbGoals();
|
|
})
|
|
.then(function(g){ goals=g; closeOv(); render(); })
|
|
.catch(function(e){
|
|
btn.disabled=false; btn.textContent='Anmelden';
|
|
if(authToken){ closeOv(); render(); }
|
|
else{ showLogin(e.status===400||e.status===401?'Falsche E-Mail oder Passwort':'Verbindungsfehler'); }
|
|
});
|
|
};
|
|
}
|
|
|
|
function renderRegisterForm(err){
|
|
return (err?'<div class="login-err">'+err+'</div>':'')+
|
|
'<div class="ssub" style="margin-bottom:8px">Eingeladen als: <strong>'+inviteRecord.label+'</strong></div>'+
|
|
'<div class="ff"><label>E-Mail</label><input class="fi" id="remail" type="email" autocomplete="email"/></div>'+
|
|
'<div class="ff"><label>Passwort</label><input class="fi" id="rpass" type="password" autocomplete="new-password"/></div>'+
|
|
'<div class="ff"><label>Passwort wiederholen</label><input class="fi" id="rpass2" type="password" autocomplete="new-password"/></div>'+
|
|
'<div class="factions"><button class="btn-p" id="rsub">Registrieren</button></div>';
|
|
}
|
|
|
|
function wireRegisterForm(){
|
|
setTimeout(function(){ var el=document.getElementById('remail'); if(el) el.focus(); },50);
|
|
document.getElementById('rsub').onclick=function(){
|
|
var email=document.getElementById('remail').value.trim();
|
|
var pass=document.getElementById('rpass').value;
|
|
var pass2=document.getElementById('rpass2').value;
|
|
if(!email||!pass||!pass2) return;
|
|
if(pass!==pass2){showLogin('Passwörter stimmen nicht überein');return;}
|
|
if(pass.length<8){showLogin('Passwort muss mindestens 8 Zeichen haben');return;}
|
|
var btn=this; btn.disabled=true; btn.textContent='…';
|
|
var savedInviteId=inviteRecord.id;
|
|
api('POST','/api/collections/users/records',{email:email,password:pass,passwordConfirm:pass2})
|
|
.then(function(){
|
|
return api('POST','/api/collections/users/auth-with-password',{identity:email,password:pass});
|
|
})
|
|
.then(function(res){
|
|
authToken=res.token; authUserId=res.record.id;
|
|
localStorage.setItem('zt_token',authToken);
|
|
localStorage.setItem('zt_uid',authUserId);
|
|
return api('PATCH','/api/collections/invites/records/'+savedInviteId,{used_by:authUserId,used_at:new Date().toISOString()});
|
|
})
|
|
.then(function(){
|
|
inviteRecord=null;
|
|
return pbGoals();
|
|
})
|
|
.then(function(g){ goals=g; closeOv(); render(); })
|
|
.catch(function(e){
|
|
btn.disabled=false; btn.textContent='Registrieren';
|
|
showLogin(e.status===400?'E-Mail bereits vergeben oder ungültig':'Registrierung fehlgeschlagen');
|
|
});
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify login still works**
|
|
|
|
Open app normally (no `?invite` param). Login works as before. "Passwort vergessen?" link appears and opens the reset sheet.
|
|
|
|
- [ ] **Step 3: Verify registration flow**
|
|
|
|
1. In PocketBase admin, create a test invite: token = `testtoken123`, label = `Testkind`, used_by = empty.
|
|
2. Open `http://miniweb.kuehn.home/?invite=testtoken123` in a private window.
|
|
3. Login sheet shows two tabs: "Anmelden" / "Registrieren".
|
|
4. Click "Registrieren" → form shows "Eingeladen als: **Testkind**".
|
|
5. Enter mismatched passwords → error "Passwörter stimmen nicht überein".
|
|
6. Enter a 7-char password → error "Passwort muss mindestens 8 Zeichen haben".
|
|
7. Enter valid email + matching passwords (8+ chars) → user created, logged in, goals list shown.
|
|
8. In PocketBase admin, confirm invite record has `used_by` and `used_at` set.
|
|
9. Open the same link again in a private window → only "Anmelden" tab visible (token is used).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add app.js
|
|
git commit -m "feat: refactor login sheet with tabs, registration form, and password-reset link"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Invite management UI
|
|
|
|
**Files:**
|
|
- Modify: `app.js`
|
|
- Modify: `style.css`
|
|
|
|
- [ ] **Step 1: Add `genToken` utility function to `app.js`**
|
|
|
|
Add after the `now()` function (around line 16):
|
|
|
|
```js
|
|
function genToken(){
|
|
var c='ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789',t='';
|
|
for(var i=0;i<12;i++) t+=c[Math.floor(Math.random()*c.length)];
|
|
return t;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add `showInvites` and `showNewInvite` functions after `openData` in `app.js`**
|
|
|
|
```js
|
|
function showInvites(){
|
|
api('GET','/api/collections/invites/records?filter='+encodeURIComponent('(created_by="'+authUserId+'")&sort=-created&expand=used_by'))
|
|
.then(function(res){
|
|
var items=res.items||[];
|
|
var rows='';
|
|
for(var i=0;i<items.length;i++){
|
|
var inv=items[i];
|
|
var usedEmail=inv.expand&&inv.expand.used_by?inv.expand.used_by.email:'registriert';
|
|
var status=inv.used_by
|
|
?'<span style="color:var(--green)">✓ '+usedEmail+'</span>'
|
|
:'<span style="color:var(--text3)">Ausstehend</span>';
|
|
rows+='<div class="inv-row"><span class="inv-lbl">'+inv.label+'</span>'+status+'</div>';
|
|
}
|
|
showSheet(
|
|
'<div class="shandle"></div>'+
|
|
'<div class="stitle">Einladungen</div>'+
|
|
(rows||'<div style="color:var(--text3);padding:12px 0;font-size:14px">Noch keine Einladungen verschickt.</div>')+
|
|
'<div class="ddiv"></div>'+
|
|
'<button class="btn-p" id="inew" style="width:100%">+ Neue Einladung</button>'+
|
|
'<button class="btn-c" id="icls" style="width:100%;margin-top:4px;text-align:center">Schließen</button>'
|
|
);
|
|
document.getElementById('icls').onclick=closeOv;
|
|
document.getElementById('inew').onclick=showNewInvite;
|
|
})
|
|
.catch(function(){ showToast('Fehler beim Laden'); });
|
|
}
|
|
|
|
function showNewInvite(){
|
|
showSheet(
|
|
'<div class="shandle"></div>'+
|
|
'<div class="stitle">Neue Einladung</div>'+
|
|
'<div class="ff"><label>Name / Für wen?</label><input class="fi" id="ilabel" type="text" placeholder="z.B. Mama"/></div>'+
|
|
'<div class="factions">'+
|
|
'<button class="btn-p" id="isub">Einladung erstellen</button>'+
|
|
'<button class="btn-c" id="iback">Zurück</button>'+
|
|
'</div>'
|
|
);
|
|
setTimeout(function(){ var el=document.getElementById('ilabel'); if(el) el.focus(); },50);
|
|
document.getElementById('iback').onclick=showInvites;
|
|
document.getElementById('isub').onclick=function(){
|
|
var label=(document.getElementById('ilabel').value||'').trim();
|
|
if(!label) return;
|
|
var btn=this; btn.disabled=true; btn.textContent='…';
|
|
var token=genToken();
|
|
api('POST','/api/collections/invites/records',{token:token,label:label,created_by:authUserId})
|
|
.then(function(){
|
|
var link=APP_URL+'/?invite='+token;
|
|
showSheet(
|
|
'<div class="shandle"></div>'+
|
|
'<div class="stitle">Einladung erstellt</div>'+
|
|
'<div class="ssub">Teile diesen Link mit '+label+':</div>'+
|
|
'<div class="inv-link" id="ilink">'+link+'</div>'+
|
|
'<div class="factions">'+
|
|
'<button class="btn-p" id="icopy">Link kopieren</button>'+
|
|
'<button class="btn-c" id="idone">Fertig</button>'+
|
|
'</div>'
|
|
);
|
|
document.getElementById('idone').onclick=closeOv;
|
|
document.getElementById('icopy').onclick=function(){
|
|
navigator.clipboard.writeText(link).then(function(){ showToast('Link kopiert!'); });
|
|
};
|
|
})
|
|
.catch(function(){ btn.disabled=false; btn.textContent='Einladung erstellen'; showToast('Fehler'); });
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add "Einladungen" button to `openData`**
|
|
|
|
In `openData`, the sheet HTML starts with the export button. Add a new button and divider at the very beginning of the sheet content (before the export button):
|
|
|
|
Replace the beginning of `openData`'s sheet HTML:
|
|
```js
|
|
'<button class="dbtn" id="dexp"><span class="dico">⬇</span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>'+
|
|
```
|
|
|
|
With:
|
|
```js
|
|
'<button class="dbtn" id="dinv"><span class="dico">✉</span><span class="dlbl">Einladungen<span class="dsub">Freunde und Familie einladen</span></span></button>'+
|
|
'<div class="ddiv"></div>'+
|
|
'<button class="dbtn" id="dexp"><span class="dico">⬇</span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>'+
|
|
```
|
|
|
|
Add the event binding after `document.getElementById('dcls').onclick=closeOv;`:
|
|
```js
|
|
document.getElementById('dinv').onclick=function(){ closeOv(); showInvites(); };
|
|
```
|
|
|
|
- [ ] **Step 4: Verify invite management**
|
|
|
|
1. Log in, open ⋯ menu → "Einladungen" button appears at the top.
|
|
2. Click → invite list sheet opens (empty).
|
|
3. "Neue Einladung" → enter "Papa" → "Einladung erstellen".
|
|
4. Result sheet shows invite link with "Link kopieren" button.
|
|
5. "Link kopieren" → toast "Link kopiert!" appears, clipboard contains the URL.
|
|
6. "Fertig" → sheet closes.
|
|
7. Open ⋯ → Einladungen → "Papa" listed as "Ausstehend".
|
|
8. Open the invite link in a private window and register.
|
|
9. Back in original session: ⋯ → Einladungen → "Papa" shows "✓ [email]".
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app.js style.css
|
|
git commit -m "feat: add invite management UI with create, list, and referral tracking"
|
|
```
|
|
|
|
---
|
|
|
|
## Done
|
|
|
|
After Task 8, the full feature set is live:
|
|
- `config.js` holds server URLs (update for production deployment)
|
|
- Invite links are the only way to register
|
|
- Password reset works via email (requires PocketBase SMTP configured in Task 3)
|
|
- Invite list shows referral status per person
|